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:
- It owns its own data source (its own request, subscription, or store slice).
- It occupies its own tile in the layout, with a size the grid already knows.
- 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.
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 #
- 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.
- Derive the fallback’s size from the layout definition.
spancomes from the dashboard config, not from the rendered widget, so a crash during measurement cannot collapse the tile. - Scope telemetry by tile id.
tile:ordersgroups cleanly and names an owner, which is what makes the fingerprinting in Deduplicating Error Noise with Fingerprinting and Sampling useful. - 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.
- 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 |
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.
Verification #
- Crash one tile. The other eleven must render, the grid must not reflow, and exactly one crash report scoped
tile:<id>must be sent. - 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.
- Retry one tile. Only that tile’s request should re-fire; watch the network panel for exactly one new request.
- 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.
- 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.
Related #
- Choosing a Boundary Granularity Strategy — the decision framework this instantiates
- When to Use One Root Boundary vs Per-Route Boundaries — the coarser end of the same spectrum
- Adding a Retry Button That Recovers Without a Full Reload — the per-tile recovery control