Search IOCombats

Search challenges, guides, questions and articles

System DesignTopic 15 of 15AdvancedAug 3, 2026

Dashboard with Widgets

Design a configurable widget dashboard with a registry, a shared data layer, per-user persisted layout, error isolation, lazy mounting, and updates that never re-render siblings.

frontend-system-designpractice-problemdashboard

The Problem

Design a dashboard: a grid of widgets - charts, counters, tables, activity feeds - that the user can rearrange and resize, with a layout that persists. Each widget shows live or periodically refreshed data.

A dashboard is the composite problem in this track. It is not one hard thing but many independent components sharing a page, a main thread, a connection budget and a data layer, and almost every failure mode comes from that sharing: fifteen widgets issuing fifteen requests, one widget's error blanking the page, one widget's update re-rendering all the others. It is also usually the surface where several teams' code meets, which makes the module boundaries as important as the runtime behaviour.

Requirements

Functional

  • Widget grid with drag-to-rearrange and resize.
  • Layout persisted per user, restored on next visit.
  • Widgets added and removed from a catalogue.
  • Per-widget refresh, with some widgets live.
  • A failing widget shows an error without affecting others.

Non-functional

  • Initial load not gated on the slowest widget.
  • One widget's data update re-renders only that widget.
  • Offscreen widgets do not consume main-thread time or connections on load.
  • Adding a new widget type requires no change to shared dashboard code.

The Widget Registry

The architectural decision that determines whether adding a widget is a one-file change or a shared-code change.

type WidgetSize = { minW: number; minH: number; defaultW: number; defaultH: number };

type WidgetDefinition<Config = unknown> = {
  type: string;
  title: string;
  description: string;
  size: WidgetSize;
  /** Code-split: the tile's code is not in the main bundle. */
  load: () => Promise<{ default: React.ComponentType<WidgetProps<Config>> }>;
  /** Declared, not fetched - the shared layer satisfies these. */
  dataRequirements: (config: Config) => DataQuery[];
  /** Validates persisted config, which is untrusted input by the time it returns. */
  configSchema: ZodType<Config>;
  defaultConfig: Config;
};

const WIDGET_REGISTRY = new Map<string, WidgetDefinition>();

export function registerWidget<Config>(definition: WidgetDefinition<Config>) {
  if (WIDGET_REGISTRY.has(definition.type)) {
    throw new Error(`Duplicate widget type: ${definition.type}`);
  }
  WIDGET_REGISTRY.set(definition.type, definition as WidgetDefinition);
}
// modules/dashboard/widgets/revenue-chart/index.ts
registerWidget({
  type: 'revenue-chart',
  title: 'Revenue',
  description: 'Revenue over time, by period',
  size: { minW: 2, minH: 2, defaultW: 4, defaultH: 3 },
  // The chart library is only downloaded if this widget is actually placed.
  load: () => import('./revenue-chart'),
  dataRequirements: (config) => [
    { resource: 'metrics', params: { metric: 'revenue', period: config.period } },
  ],
  configSchema: z.object({ period: z.enum(['7d', '30d', '90d']) }),
  defaultConfig: { period: '30d' },
});

Four properties earn the registry its place:

Adding a widget touches one directory. The dashboard shell iterates the registry; it has no knowledge of any specific widget. This is the module-boundary discipline from Application Architecture at Scale, and on a dashboard owned by several teams it is what prevents every new tile from becoming a change to shared code.

load is a dynamic import, so a chart library used by one widget is not in the main bundle. On a dashboard with a dozen widget types drawing on different libraries, this is usually the single largest bundle win available.

dataRequirements declares rather than fetches, which is what lets the shared layer deduplicate and prefetch. A widget that calls fetch itself cannot participate in that.

configSchema validates persisted config. A layout record is stored data written by an older version of your code and potentially edited by hand - it is untrusted by the time it comes back, which is the input-validation argument from Security Architecture applied to configuration.

Data: Independent vs Shared

Diagram
100%
flowchart TB subgraph SHELL["Dashboard shell - knows nothing about specific widgets"] REG[("widget registry<br/>type -> definition")] LAYOUT[("layout state<br/>positions + sizes + config")] GRID["grid renderer"] end subgraph WIDGETS["Widget instances - independent, code-split"] W1["revenue-chart<br/>needs metrics:revenue:30d"] W2["orders-counter<br/>needs metrics:orders:30d"] W3["conversion-rate<br/>needs metrics:revenue:30d<br/>+ metrics:orders:30d"] W4["activity-feed<br/>needs stream:activity"] end subgraph DATA["Shared data layer - keyed by resource, not by widget"] CACHE[("query cache<br/>metrics:revenue:30d<br/>metrics:orders:30d<br/>stream:activity")] DEDUP["in-flight deduplication:<br/>W1 and W3 share ONE request"] POLL["one scheduler for all<br/>polled resources"] WS["one live connection,<br/>fanned out by resource"] end subgraph SERVER["Server"] BATCH["batched metrics endpoint"] STREAM["event stream"] end REG --> GRID LAYOUT --> GRID GRID --> W1 & W2 & W3 & W4 W1 -->|"subscribe(key)"| CACHE W2 -->|"subscribe(key)"| CACHE W3 -->|"subscribe(2 keys)"| CACHE W4 -->|"subscribe(key)"| CACHE CACHE --> DEDUP --> BATCH CACHE --> POLL --> BATCH CACHE --> WS --> STREAM LAYOUT -->|"debounced save"| PERSIST[("server record<br/>+ local mirror")] style SHELL fill:#0f172a,stroke:#334155 style WIDGETS fill:#1e3a5f,stroke:#3b82f6 style DATA fill:#1e3f2d,stroke:#22c55e style DEDUP fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats

Note revenue-chart and conversion-rate both needing metrics:revenue:30d. With independent fetching that is two requests for identical data. With a resource-keyed cache it is one request, one cache entry, and both widgets re-render when it resolves.

type DataQuery = { resource: string; params: Record<string, string> };

function queryKey({ resource, params }: DataQuery): string {
  // Stable key regardless of property order, so two widgets expressing the same
  // requirement differently still share one cache entry.
  const sorted = Object.keys(params).sort().map((key) => `${key}=${params[key]}`);
  return `${resource}?${sorted.join('&')}`;
}

function useWidgetData<T>(query: DataQuery): WidgetDataState<T> {
  const key = queryKey(query);

  // Selector-scoped subscription: this widget re-renders only when ITS key
  // changes, not when any other widget's data does.
  return useDashboardStore(
    (state) => state.queries[key] as WidgetDataState<T>,
    shallowEqual,
  );
}

The batching layer collapses many small requests into one, which matters because a browser allows roughly six concurrent connections per origin - fifteen widget requests means a queue, and the last widget in it waits for three rounds:

const BATCH_WINDOW_MS = 20;

class MetricsBatcher {
  private pending = new Map<string, { query: DataQuery; resolvers: Resolver[] }>();
  private timer: ReturnType<typeof setTimeout> | null = null;

  request(query: DataQuery): Promise<unknown> {
    const key = queryKey(query);

    return new Promise((resolve, reject) => {
      const existing = this.pending.get(key);

      // Deduplication: a second widget wanting the same key joins the first
      // request instead of starting another.
      if (existing) {
        existing.resolvers.push({ resolve, reject });
      } else {
        this.pending.set(key, { query, resolvers: [{ resolve, reject }] });
      }

      // Collect everything requested in the same tick, then send once.
      this.timer ??= setTimeout(() => void this.flush(), BATCH_WINDOW_MS);
    });
  }

  private async flush() {
    const batch = Array.from(this.pending.values());
    this.pending.clear();
    this.timer = null;

    try {
      const results = await api.batchMetrics(batch.map((entry) => entry.query));
      batch.forEach((entry, index) => {
        // Partial failure is per-query: one bad metric must not fail the batch.
        const result = results[index];
        for (const resolver of entry.resolvers) {
          result.ok ? resolver.resolve(result.data) : resolver.reject(new Error(result.error));
        }
      });
    } catch (error) {
      for (const entry of batch) {
        for (const resolver of entry.resolvers) resolver.reject(error);
      }
    }
  }
}

Per-query partial failure is the important detail. A batch endpoint that fails wholesale when one metric is broken converts a single widget's problem into an empty dashboard - which is exactly the coupling batching was supposed to be worth accepting.

For refresh, one scheduler beats per-widget timers:

class RefreshScheduler {
  private subscriptions = new Map<string, { intervalMs: number; lastRunAt: number }>();

  constructor() {
    // A single timer for the whole dashboard. Fifteen independent setIntervals
    // wake the main thread fifteen times and drift out of phase.
    setInterval(() => this.tick(), 1_000);

    document.addEventListener('visibilitychange', () => {
      // Polling a hidden tab burns battery and quota for nobody.
      if (document.visibilityState === 'visible') this.tick();
    });
  }

  private tick() {
    if (document.visibilityState !== 'visible') return;

    const now = Date.now();
    for (const [key, subscription] of this.subscriptions) {
      if (now - subscription.lastRunAt < subscription.intervalMs) continue;
      subscription.lastRunAt = now;
      void refetch(key);
    }
  }
}

Live widgets should share one connection, fanned out by resource, rather than opening one each. The transport, heartbeats and reconnection are those built in Real-Time Feed; the dashboard adds only the routing of events to subscribed resource keys. The cache-key design and deduplication are the concerns of Networking and Data Fetching.

Error Isolation

An uncaught render error propagates until something catches it. One boundary around the grid means one widget's failure blanks the entire dashboard.

class WidgetErrorBoundary extends React.Component<Props, State> {
  state: State = { error: null };

  static getDerivedStateFromError(error: Error): State {
    return { error };
  }

  componentDidCatch(error: Error, info: React.ErrorInfo) {
    // Tagged so a single misbehaving widget type is visible in monitoring
    // before users report it.
    Sentry.withScope((scope) => {
      scope.setTag('widget.type', this.props.widgetType);
      scope.setTag('widget.id', this.props.widgetId);
      scope.setContext('componentStack', { value: info.componentStack });
      Sentry.captureException(error);
    });
  }

  render() {
    if (this.state.error) {
      return (
        <WidgetShell title={this.props.title}>
          <div className='flex flex-col items-center gap-2 p-4 text-sm'>
            <AlertTriangle className='h-5 w-5 text-amber-500' aria-hidden />
            <p className='text-muted-foreground'>This widget could not be displayed.</p>
            <Button size='sm' variant='outline' onClick={() => this.setState({ error: null })}>
              Retry
            </Button>
          </div>
        </WidgetShell>
      );
    }

    return this.props.children;
  }
}
function WidgetTile({ instance }: { instance: WidgetInstance }) {
  const definition = WIDGET_REGISTRY.get(instance.type);

  // A layout record from an older release can reference a widget that no longer
  // exists. Drop it rather than crashing the grid.
  if (!definition) return <UnknownWidgetTile type={instance.type} />;

  const config = definition.configSchema.safeParse(instance.config);
  if (!config.success) return <MisconfiguredWidgetTile title={definition.title} />;

  const Widget = useLazyComponent(definition.load);

  return (
    <WidgetErrorBoundary
      widgetId={instance.id}
      widgetType={instance.type}
      title={definition.title}>
      {/* Suspense inside the boundary: a chunk that fails to load is an error
          the boundary should catch, not a permanent spinner. */}
      <Suspense fallback={<WidgetSkeleton title={definition.title} />}>
        <Widget instanceId={instance.id} config={config.data} />
      </Suspense>
    </WidgetErrorBoundary>
  );
}

Four layers of isolation, because error boundaries only catch render errors:

  1. A boundary per widget for render errors.
  2. Per-widget data error state for rejected fetches, which never reach a boundary. Each tile renders its own error with a retry.
  3. Unknown and misconfigured widget handling, since persisted layouts outlive releases.
  4. Reporting tagged by widget type, so one broken tile is visible in monitoring rather than discovered from support tickets. The instrumentation reasoning is in Observability.

Errors thrown inside event handlers, timers or promise callbacks reach no boundary at all - they need explicit handling or a global unhandledrejection listener. Assuming a boundary covers everything is the common misconception.

Layout, Drag and Persistence

Layout is per-user configuration.

type WidgetInstance = {
  id: string;
  type: string;
  /** Grid units, not pixels - the grid resolves them per breakpoint. */
  position: { x: number; y: number; w: number; h: number };
  config: unknown;
};

type DashboardLayout = {
  /** Versioned, so the grid model can change without breaking stored records. */
  version: 2;
  widgets: WidgetInstance[];
  updatedAt: number;
};

const SAVE_DEBOUNCE_MS = 1_000;

const saveLayout = debounce(async (layout: DashboardLayout) => {
  // Mirror locally first so a reload before the save completes is not a loss.
  localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
  await api.saveDashboardLayout(layout);
}, SAVE_DEBOUNCE_MS);

function loadLayout(stored: unknown): DashboardLayout {
  const parsed = DashboardLayoutSchema.safeParse(stored);
  if (!parsed.success) return defaultLayout();

  const migrated = migrateLayout(parsed.data);

  // Drop widgets whose type has been removed since the layout was saved.
  const known = migrated.widgets.filter((widget) => WIDGET_REGISTRY.has(widget.type));

  // Append widgets added since, or a new widget type is invisible to every
  // existing user - a silent, easily-missed regression.
  const missing = defaultWidgets().filter(
    (candidate) => !known.some((widget) => widget.type === candidate.type),
  );

  return { ...migrated, widgets: [...known, ...appendBelow(known, missing)] };
}

Four load-time cases, each a real bug if skipped: validate the stored record, migrate it if the version is old, drop unknown widget types, and append newly added defaults. The last is the one most often missed, and its symptom is that a new widget ships and nobody who has ever customised their dashboard sees it.

Dragging is the interaction from Drag and Drop, with two dashboard-specific additions:

const GRID_COLUMNS = 12;

function snapToGrid(pixel: { x: number; y: number }, cell: { w: number; h: number }) {
  return {
    x: Math.max(0, Math.min(GRID_COLUMNS - 1, Math.round(pixel.x / cell.w))),
    y: Math.max(0, Math.round(pixel.y / cell.h)),
  };
}

/** Push overlapped widgets down rather than allowing them to overlap. */
function resolveCollisions(
  widgets: WidgetInstance[],
  moved: WidgetInstance,
): WidgetInstance[] {
  const others = widgets.filter((widget) => widget.id !== moved.id);
  const resolved = [moved];

  for (const widget of [...others].sort((a, b) => a.position.y - b.position.y)) {
    let candidate = widget;
    while (resolved.some((placed) => overlaps(placed.position, candidate.position))) {
      candidate = { ...candidate, position: { ...candidate.position, y: candidate.position.y + 1 } };
    }
    resolved.push(candidate);
  }

  return resolved;
}

And a keyboard path, which is the requirement most dashboards skip entirely:

function onWidgetKeyDown(event: React.KeyboardEvent, widget: WidgetInstance) {
  if (mode !== 'grabbed') {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      setMode('grabbed');
      announce(`${widget.title} grabbed. Arrow keys to move, Enter to place, Escape to cancel.`);
    }
    return;
  }

  const deltas: Record<string, { x: number; y: number }> = {
    ArrowLeft: { x: -1, y: 0 },
    ArrowRight: { x: 1, y: 0 },
    ArrowUp: { x: 0, y: -1 },
    ArrowDown: { x: 0, y: 1 },
  };

  const delta = deltas[event.key];
  if (delta) {
    event.preventDefault();
    moveWidget(widget.id, delta);
    announce(`Column ${widget.position.x + 1}, row ${widget.position.y + 1}.`);
    return;
  }

  if (event.key === 'Escape') {
    event.preventDefault();
    restoreOriginalLayout(); // full restore, not one inverse move
    setMode('idle');
    announce('Move cancelled.');
  }
}

The reasoning - why a second interaction model is required rather than synthesised drag events, and why every transition needs announcing - is in Accessibility Architecture. A dashboard adds the wrinkle that a two-dimensional grid needs both axes announced, so "column 3, row 2" rather than "position 5".

Render Isolation

The failure this section prevents: one widget's data arrives and all fifteen re-render. On a dashboard with charts, that is easily a hundred milliseconds of wasted main-thread work per update.

Diagram
100%
flowchart TB subgraph BAD["ANTI-PATTERN - one object above the widgets"] B1["parent holds { revenue, orders, activity, ... }"] B2["revenue arrives -> setState"] B3["new object identity"] B4["parent re-renders"] B5["ALL 15 widgets re-render:<br/>15 chart redraws, ~100ms of<br/>main-thread work for one change"] B1 --> B2 --> B3 --> B4 --> B5 end subgraph GOOD["ISOLATED - per-key selector subscriptions"] G1["store holds queries keyed by resource"] G2["revenue arrives -> store.set('metrics:revenue:30d')"] G3["only subscribers of THAT key are notified"] G4["revenue-chart re-renders<br/>conversion-rate re-renders (it uses the key)"] G5["orders-counter: NOT notified<br/>activity-feed: NOT notified<br/>grid shell: NOT notified"] G1 --> G2 --> G3 --> G4 G3 --> G5 end style BAD fill:#3f1e1e,stroke:#ef4444 style GOOD fill:#1e3f2d,stroke:#22c55e
visualized byIOCombats
/** Layout and data are separate stores: a resize must not invalidate data. */
const useDashboardStore = create<DashboardState>()((set) => ({
  queries: {},
  setQuery: (key, value) =>
    // Only the changed key gets a new identity; other entries keep theirs, so
    // shallow-comparing selectors on other keys see no change.
    set((state) => ({ queries: { ...state.queries, [key]: value } })),
}));

const useLayoutStore = create<LayoutState>()(/* ... */);
const RevenueChart = React.memo(function RevenueChart({ config }: WidgetProps<Config>) {
  // Scoped subscription: this component is notified for one key only.
  const data = useWidgetData<RevenuePoint[]>({
    resource: 'metrics',
    params: { metric: 'revenue', period: config.period },
  });

  // Charts are expensive to re-derive; memoise the transform, not just the view.
  const series = useMemo(() => toSeries(data.value ?? []), [data.value]);

  if (data.status === 'loading') return <WidgetSkeleton />;
  if (data.status === 'error') return <WidgetError onRetry={data.retry} />;

  return <LineChart series={series} />;
});

Four techniques, in order of importance:

  1. Selector-based subscriptions. A store that notifies per key is the structural fix. A context value holding all widget data cannot be fixed by memoisation, because the value itself changes identity on every update.
  2. Separate layout and data stores. Otherwise a drag invalidates data subscriptions and every widget refetches or re-renders mid-gesture.
  3. React.memo on widget components, which only helps if props are stable - so config objects must come from the store rather than being constructed inline.
  4. Memoise expensive derivations, not just the render. A chart's data transform is often more expensive than its render.

useMemo and memo are mitigations; the store shape is the fix. This is the granularity argument from State Management Architecture, and the main-thread cost it avoids is quantified in Performance Engineering.

Lazy Mounting

A dashboard is usually taller than the viewport. Mounting a widget means rendering it, subscribing its queries and initialising its chart library - work that produces nothing visible for a tile the user has not scrolled to.

function DeferredWidget({ instance, definition }: DeferredWidgetProps) {
  const tileRef = useRef<HTMLDivElement>(null);
  const [shouldMount, setShouldMount] = useState(false);

  useEffect(() => {
    const element = tileRef.current;
    if (!element || shouldMount) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (!entry.isIntersecting) return;
        setShouldMount(true);
        observer.disconnect(); // mounted widgets stay mounted
      },
      // Start mounting before the tile is visible so it is ready on arrival.
      { rootMargin: '200px' },
    );

    observer.observe(element);
    return () => observer.disconnect();
  }, [shouldMount]);

  return (
    <div
      ref={tileRef}
      // Space reserved from the layout record, so mounting causes no shift.
      style={{ gridColumn: `span ${instance.position.w}`, minHeight: instance.position.h * ROW_HEIGHT }}>
      {shouldMount ? (
        <WidgetTile instance={instance} />
      ) : (
        <WidgetSkeleton title={definition.title} />
      )}
    </div>
  );
}

Two requirements. The tile reserves its space from the layout record, so mounting causes no layout shift and the grid does not jump as the user scrolls. And widgets stay mounted once mounted - unmounting on scroll-away would discard chart state and cause a refetch on return, which costs more than it saves for the widget counts a dashboard actually has.

One design consequence worth naming: aggregates cannot live inside widgets. If a total at the top of the page sums values from three widgets, it cannot depend on those widgets having mounted. Aggregates belong in the shared data layer, computed from cache entries rather than from component state - which is another reason the data layer is keyed by resource rather than by widget.

Common Interview Follow-Up Questions

"Different teams own different widgets and deploy independently. How does that work?" The registry is the contract, and independent deployment means widget code must be loaded at runtime rather than bundled at build time - module federation or a manifest of remotely-hosted widget bundles the shell fetches and registers. The hard parts are not the loading but the boundaries: a shared design system and shared React must be singletons or you ship two copies and break hooks, the widget props interface becomes a versioned public API that cannot change without coordination, and a widget from a team that has broken its build must fail to load without taking the shell down - which makes the error boundary and the unknown-widget fallback load-bearing rather than defensive. The full treatment of runtime composition, shared dependencies and independent deployment is in Application Architecture at Scale.

"The dashboard takes six seconds to become interactive. Where do you look?" Break it into three buckets and measure each rather than guessing. Bundle: is every widget's chart library in the main chunk because the registry imports components statically instead of via dynamic load? That is the most common single cause. Requests: are fifteen widgets issuing fifteen unbatched requests and queueing behind the connection limit, so the last widget waits for three rounds? Main thread: are all fifteen widgets mounting and initialising during load when only four are visible? Those three fixes - code-split the registry, batch and deduplicate the data layer, defer offscreen mounting - typically account for most of the gap, and they are independent so they can be shipped separately.

"How do you handle a widget that needs to refresh every second?" Question the requirement first, because per-second polling of a metric that changes hourly is waste - but where it is genuine, the mechanism is a live connection rather than a one-second poll, since polling at that rate is effectively a connection with extra overhead. The dashboard concerns are throttling the render rather than the data, coalescing updates to one per animation frame so a chart is not redrawn more often than the display refreshes, and pausing entirely when the tab is hidden or the widget is scrolled out of view. A high-frequency widget is also the one most likely to cause sibling re-renders, so it is the sharpest test of whether the selector subscriptions are correctly scoped.

"A widget contains a table of 50,000 rows. What changes?" The widget needs windowing internally, so it inherits everything from Virtualized List - but the dashboard context adds constraints. The widget has a fixed height set by the grid, so its scroll container is nested inside the page's, which means the virtualiser must use the widget element as its observation root rather than the viewport. Resizing the widget changes the visible row count, so the virtualiser must re-measure on resize, and that resize is happening during a drag gesture where the main thread is already busy. It is usually better to keep the tile small with a "view all" link to a full-page table than to put a large virtualised table inside a resizable grid cell.

"How do you test a dashboard?" Test the shell and the widgets separately, which is the payoff of the registry. The shell's tests use a fake registry of trivial widgets and cover the cases that actually break in production: an unknown widget type in a stored layout, a widget whose config fails validation, a widget that throws on render and must not affect its neighbours, and a layout migration from the previous version. The data layer is pure enough to test directly - two queries with the same key producing one request, per-query partial failure, and a batch that rejects wholesale. Render isolation needs an explicit test with a render counter asserting that updating one key does not re-render a sibling, because that regression is invisible and creeps back in easily. Each widget is then tested in isolation with mocked data. The layering rationale is in Testing Strategy.

Tradeoffs Table

OptionProsConsWhen to Use
Independent per-widget fetchingFully self-contained widgets, no shared couplingDuplicate requests for shared data, connection-limit queueing, no prefetchFew widgets with genuinely disjoint data
Single orchestrating fetchOne request, minimum overheadEvery widget coupled to one endpoint; adding a widget changes shared codeFixed dashboards that are never extended
Resource-keyed shared cacheAutomatic deduplication, one request per resource, widgets stay independentA data layer to build and cache keys to design carefullyDefault for any extensible dashboard
One error boundary around the gridTrivialOne widget's failure blanks every widgetNever
Error boundary per widgetFailures confined to one tile; the rest keeps workingA boundary and fallback per tile; async errors still need separate handlingDefault, always
Mount all widgets on loadSimple; everything ready immediatelyOffscreen widgets consume main thread and connections when it matters mostShort dashboards that fit in one viewport
Lazy-mount offscreen widgetsVisible widgets get the whole budget during loadSpace must be reserved; aggregates cannot live inside widgetsDashboards taller than the viewport
Shared state object above widgetsEasy to write and reason aboutAny update re-renders every widget; memoisation cannot fix itNever on a dashboard of expensive widgets
Per-key selector subscriptionsOne widget's update re-renders one widgetNeeds a store with selector support and disciplined key designDefault for any dashboard with charts

Where This Applies

A dashboard is the composite problem in this track, and it draws its structure from Application Architecture at Scale: a registry as the contract between a shell and independently-owned widgets is the same boundary discipline that makes multi-team frontends possible, and it is what keeps adding a widget from becoming a change to shared code. It is also where the granularity argument in State Management Architecture has the most visible cost, since coarse subscriptions turn one data update into fifteen chart redraws. And the three load-time bottlenecks - bundle, requests, main thread - are the three budgets described in Performance Engineering, all under pressure on the same page.

Within this track it composes several earlier problems. The grid interaction is Drag and Drop, including the keyboard path that a two-dimensional grid makes slightly harder. Live widgets share the connection built in Real-Time Feed rather than opening one each. Table-heavy widgets need Virtualized List, and the deferred-mounting mechanism is the same Intersection Observer pattern used in Image Gallery with Lazy Loading.

Advertisement

Frequently Asked Questions

Should each widget fetch its own data, or should a shared data layer fetch for all of them?

Each widget should declare what it needs and a shared layer should satisfy those declarations, which gets the benefits of both. Fully independent fetching keeps widgets self-contained and independently deployable, but a dashboard of fifteen widgets then issues fifteen requests on load, several of which want overlapping data, and the browser's connection limit turns them into a queue. A single orchestrating fetch is efficient but couples every widget to one endpoint and makes adding a widget a change to shared code, which defeats the point of a widget system. The resolution is a query layer keyed by resource where widgets subscribe rather than fetch - two widgets needing the same metric share one request and one cache entry, deduplication happens automatically, and a widget still owns its own data requirements without knowing anything about its siblings.

How do you stop one broken widget from taking down the whole dashboard?

With an error boundary per widget rather than one around the grid, because an uncaught render error propagates until something catches it, and a single boundary at the top means one widget's failure blanks every widget. A per-widget boundary confines the failure to that tile, which then renders a fallback with a retry while its neighbours keep working. Error boundaries only catch errors thrown during rendering, so asynchronous failures need handling too - a rejected fetch has to be surfaced as widget state rather than left to reject silently, and an error thrown inside an event handler or a timer never reaches a boundary at all. The complete answer is a boundary for render errors, per-widget error state for data errors, and a report to your monitoring tagged with which widget failed so a single misbehaving tile is visible before users report it.

Why should widgets outside the viewport not be mounted, and what does that require?

Because a dashboard is usually taller than the screen, and mounting a widget means running its render, subscribing its queries and initialising whatever chart library it uses - work that produces nothing visible for a tile the user has not scrolled to. Deferring it means the visible widgets get the whole main thread and the whole connection budget during load, which is when it matters most. It requires two things. The tile must reserve its space before its content exists, or mounting on scroll causes layout shift and the grid jumps as the user moves. And the widget's data must not be needed by anything else - a total displayed at the top of the page cannot depend on a widget that has never mounted, which is a good reason for aggregates to live in the shared layer rather than being computed inside a tile.

What causes a single widget's update to re-render every other widget?

Shared state held above the widgets combined with subscriptions that are too coarse. If all widget data lives in one object in a parent component or in one context value, then updating any part of it produces a new object identity, every consumer sees a change, and all of them re-render even though only one widget's data differs. The fixes are structural rather than cosmetic. Subscribe per widget with a selector so a component only re-renders when its own slice changes, keep the data in a store that supports selector-based subscriptions rather than in a context value that replaces wholesale, and make sure the layout state and the data state are separate so a resize does not invalidate data or vice versa. Memoising the widget component is worth doing but it is a mitigation, not a fix - if the props it receives are recreated every render, memoisation cannot help.

How should widget layout be persisted, and what has to be handled on load?

Layout is per-user configuration, so it belongs in the same place other user preferences live - a server record for signed-in users, keyed by user and dashboard, with local storage as an offline mirror so a drag feels instant and survives a reload before the save completes. Writes should be debounced, since a drag produces continuous position changes and only the final one matters. Three things must be handled on load. The stored layout will reference widgets that no longer exist after a release, so unknown ids are dropped rather than allowed to crash the grid. Newly added widgets have no stored position and need a default placement appended rather than being invisible. And the layout must be versioned so a change to the grid model can migrate old records instead of rendering a broken arrangement.