This page applies the trade-offs in Choosing a Boundary Granularity Strategy to the layout that stresses them hardest: a dashboard where a dozen independent widgets share one screen.

The exact problem #

A dashboard renders twelve tiles, each fetching from a different service. One service degrades and its widget throws. With a single route-level boundary, all twelve tiles vanish and the user sees a full-page error for a page that is 92% healthy.

The obvious fix — a boundary per widget — is right, but teams then overshoot: boundaries appear around chart legends, around table rows, around individual metric values. The tree fills with wrappers, every one needs a fallback design, every one becomes a telemetry scope, and reviewing a pull request means reasoning about nested catch semantics three levels deep. The cure becomes its own disease.

The resolution is a contract that says exactly what earns a boundary.

The widget contract #

A component gets a boundary when all three hold:

  1. It owns its own data source (its own request, subscription, or store slice).
  2. It occupies its own tile in the layout, with a size the grid already knows.
  3. Its absence is comprehensible to a user — “Revenue is unavailable” is a sentence they can act on.

Everything else inherits the boundary of the tile it lives in. A legend that throws takes its chart with it, which is correct: a chart without a legend is not a working chart.

Boundary units on a dashboard grid A three-by-two grid of tiles. Five tiles render normally and are labelled with their data source. One tile is drawn dashed and labelled fallback, keeping the same size and position. A caption notes that components inside a tile, such as a legend or a table row, share the tile's boundary rather than having their own. Revenue chart + legend share one boundary Orders — unavailable same size, same slot, retry inside Conversion unaffected Traffic sources own request, own boundary Recent activity rows share the tile boundary Alerts own subscription six tiles, six boundaries — never one per legend, row or metric

Zero-to-working tile wrapper #

Wrapping happens once, in the grid renderer, so no widget author can forget it and no reviewer has to check for it.

// DashboardGrid.tsx
interface TileDef {
  id: string;
  title: string;
  /** Grid geometry drives the fallback's size, so a crash never reflows. */
  span: { cols: number; rows: number };
  render: () => React.ReactNode;
}

export function DashboardGrid({ tiles }: { tiles: TileDef[] }) {
  const [failed, setFailed] = useState<Set<string>>(new Set());

  // Platform-level failure: most tiles down means the problem is not per-widget.
  const majorityDown = failed.size > tiles.length / 2;
  if (majorityDown) return <DashboardOutage failedCount={failed.size} total={tiles.length} />;

  return (
    <div className="dash-grid">
      {tiles.map((tile) => (
        <TileFrame key={tile.id} title={tile.title} span={tile.span}>
          <RetryableBoundary
            scope={`tile:${tile.id}`}
            onError={() => setFailed((prev) => new Set(prev).add(tile.id))}
            onRecovered={() => setFailed((prev) => {
              const next = new Set(prev);
              next.delete(tile.id);
              return next;
            })}
            fallback={<TileError title={tile.title} />}
          >
            {tile.render()}
          </RetryableBoundary>
        </TileFrame>
      ))}
    </div>
  );
}
// TileFrame.tsx — one chrome, used by both healthy and failed states
export function TileFrame({ title, span, children }: TileFrameProps) {
  return (
    <section
      className="tile"
      style={{ gridColumn: `span ${span.cols}`, gridRow: `span ${span.rows}` }}
      aria-labelledby={`tile-${slug(title)}`}
    >
      <h3 id={`tile-${slug(title)}`} className="tile-title">{title}</h3>
      <div className="tile-body">{children}</div>
    </section>
  );
}

Because the frame — title, border, grid span — lives outside the boundary, a failed tile keeps its heading and its footprint. The user reads “Orders” with an inline message underneath, not an anonymous grey box, and nothing on the page moves. The measurement discipline behind that is the same one in Implementing Fallback Rendering Without Layout Shift.

Step-by-step #

  1. Put the boundary inside the frame, not around it. Around the frame, a crash removes the title and the grid slot; inside, the chrome survives and only the body swaps.
  2. Derive the fallback’s size from the layout definition. span comes from the dashboard config, not from the rendered widget, so a crash during measurement cannot collapse the tile.
  3. Scope telemetry by tile id. tile:orders groups cleanly and names an owner, which is what makes the fingerprinting in Deduplicating Error Noise with Fingerprinting and Sampling useful.
  4. Track failures at grid level. A count is enough to detect the platform case — a shared auth failure or a gateway outage — and switch to one honest message instead of twelve identical ones.
  5. Budget the boundaries. A lint rule capping boundary components per route file (four is generous outside the grid renderer) turns sprawl into a review conversation rather than a slow drift.
Candidate for a boundary Verdict Reason
Dashboard tile with its own query Yes Own data, own slot, nameable absence
Chart legend inside a tile No Meaningless without its chart
Table row inside a list tile No Failure is the list’s problem, not the row’s
Modal dialog launched from a tile Yes Own lifecycle, own data, dismissible
Global navigation Yes, one Its failure is categorically different
Per-metric number in a KPI strip No Wrap the strip, not the numbers
Third-party embed in a tile Yes, plus a sandbox Untrusted code needs stronger isolation
Boundary count under sprawl versus a tile contract Two columns. The sprawl column shows thirty-one boundaries across tiles, legends, rows and metrics, with notes on the extra fallback designs, telemetry scopes and tests it creates. The tile contract column shows seven boundaries with the same user-visible containment. sprawl tile contract 31 boundaries 7 boundaries 31 fallback states to design 31 telemetry scopes to triage 31 reset paths to test nested catches to reason about 1 shared fallback shell 7 scopes, one per owner 1 reset path, parameterised flat tree, reviewable diff identical containment from the user's point of view

Escalating when it is not a widget problem #

Twelve tiles failing simultaneously is not twelve bugs. It is one — an expired token, a gateway outage, a bad deploy — and showing twelve identical error cards makes the product look broken in a way that misrepresents the cause.

// DashboardOutage.tsx
export function DashboardOutage({ failedCount, total }: { failedCount: number; total: number }) {
  return (
    <section role="alert" className="dash-outage">
      <h2>Dashboard data is unavailable</h2>
      <p>
        {failedCount} of {total} panels could not load, which usually means a service is
        down rather than a problem with any single panel. Your saved views and filters are untouched.
      </p>
      <button type="button" onClick={() => window.location.reload()}>Try loading again</button>
    </section>
  );
}

If the shared cause is authentication rather than a data service, the correct move is not a page-level message at all but an escalation to the session boundary described in Rethrowing Unrecoverable Errors to a Parent Boundary — the user needs to sign in again, and no amount of retrying tiles will help.

When per-tile fallbacks become one page-level message A horizontal scale from zero to twelve failed tiles. Below the halfway threshold the label reads per-tile fallbacks, keep the grid. Above the threshold the label reads one page-level outage message. A marker at the halfway point is labelled majority threshold. majority threshold 0 failed 12 failed per-tile fallbacks, grid intact one page-level outage message independent widget failures shared cause: service or session

Verification #

  1. Crash one tile. The other eleven must render, the grid must not reflow, and exactly one crash report scoped tile:<id> must be sent.
  2. Crash seven of twelve. The page-level outage message must replace the grid, and the report count must not be seven identical page-level errors.
  3. Retry one tile. Only that tile’s request should re-fire; watch the network panel for exactly one new request.
  4. Count boundaries in the built tree. React DevTools → search for your boundary component; the count should equal the tile count plus your fixed platform boundaries, not grow with content.
  5. Check keyboard order after a failure. The failed tile keeps its heading in the document, so tab order and screen-reader navigation are unchanged — verify with a screen reader, following Building Accessible Error Fallback UI with ARIA Live Regions.

Frequently asked questions #

How many error boundaries is too many?

When boundaries stop mapping to something a user can name. One per tile is meaningful; one per button or row multiplies fallback states, telemetry scopes and tests without changing anyone’s experience.

Should each dashboard tile have its own retry?

Yes — a tile is independently refreshable by definition. Keep the retry inside the tile chrome so the grid never reflows, and back off per tile rather than globally.

What if a widget crashes during the grid’s layout measurement?

Give every tile an intrinsic size from its layout definition rather than its rendered content, so a crash during measurement leaves the geometry unchanged.