Route-Level Error Boundaries in a SPA
After reading this you will be able to place error boundaries at the right levels of a route tree so a failing view degrades into a recoverable panel instead of a blank page — and so the boundary resets automatically when the user navigates away.
← Back to Fallback Routing Strategies
Prerequisites
Core Concept
An unhandled error in a component tree does not stay local. Without a boundary, the framework unmounts everything from the error upward, which in a single-page application means the entire document body becomes empty. The user loses not just the failing panel but the navigation, the header, and any way to get anywhere else — with the address bar still showing a URL that looks fine.
An error boundary is a component that catches errors thrown by its descendants during render or data loading, and renders a fallback in their place. Everything outside it keeps running. The interesting question is therefore not how to write one — every framework provides the primitive — but where to put them, because the boundary’s position determines exactly how much of the screen dies.
The rule that follows from that: a boundary belongs at every level where the surrounding UI would still be useful without the level below it. A dashboard panel gets one, because the dashboard is useful without that panel. The route layout gets one, because the application is useful without that area. The application root gets one as a last resort, because a broken page that offers a reload button is better than a white screen.
There is a second concern unique to routing. A boundary that has caught an error stays in its error state until something resets it. If the user navigates to a working route and the boundary is still mounted — which is exactly what happens with a shared layout — they will see the error fallback on a page that is perfectly fine. The boundary must be keyed on the route so a navigation rebuilds it.
Implementation
The boundary itself is small. What makes it a route-level boundary is the resetKey, which is the current path: when the path changes, the component is rebuilt from scratch and any caught error is discarded.
// TypeScript 5.x — a framework-agnostic boundary, expressed as a small class
interface BoundaryOptions {
/** Changing this value discards a caught error — pass the current pathname. */
resetKey: string;
/** Where to report; keep it cheap and never let it throw. */
onError?: (error: unknown, resetKey: string) => void;
/** Rendered instead of the children when an error was caught. */
fallback: (error: unknown, retry: () => void) => Node;
}
export class RouteErrorBoundary {
private caught: unknown = null;
private key: string;
constructor(private options: BoundaryOptions) {
this.key = options.resetKey;
}
render(children: () => Node): Node {
// A navigation resets the boundary: same instance, clean slate.
if (this.options.resetKey !== this.key) {
this.key = this.options.resetKey;
this.caught = null;
}
if (this.caught !== null) {
return this.options.fallback(this.caught, () => {
this.caught = null; // retry re-runs the children on the next render
});
}
try {
return children();
} catch (error) {
this.caught = error;
this.options.onError?.(error, this.key);
return this.options.fallback(error, () => {
this.caught = null;
});
}
}
}
Synchronous render errors are only half the story. Data loaders reject asynchronously, and a rejected promise never reaches a try/catch around a render call. Route loaders therefore need their errors converted into a value the boundary can see:
// TypeScript 5.x — turn a rejected loader into a renderable error state
type LoadResult<T> = { ok: true; data: T } | { ok: false; error: unknown };
export async function safeLoad<T>(load: () => Promise<T>): Promise<LoadResult<T>> {
try {
return { ok: true, data: await load() };
} catch (error) {
// Rethrowing during render lets the nearest boundary catch it synchronously.
return { ok: false, error };
}
}
export function renderRoute<T>(result: LoadResult<T>, view: (data: T) => Node): Node {
if (!result.ok) throw result.error; // now inside the boundary's try/catch
return view(result.data);
}
And the wiring, one boundary per level of the chain:
// TypeScript 5.x — a boundary at each level that can fail independently
function renderChainWithBoundaries(chain: Matched[], pathname: string): Node | null {
let child: Node | null = null;
for (let i = chain.length - 1; i >= 0; i--) {
const level = chain[i];
const captured = child;
const boundary = boundaryFor(level.route, {
resetKey: pathname, // reset on every navigation
onError: (err) => report(err, { route: level.route.path, pathname }),
fallback: (err, retry) => errorPanel(err, retry, level.route.path),
});
child = boundary.render(() =>
level.route.render({ params: level.params, outlet: () => captured, data: {} }),
);
}
return child;
}
Verification
// @playwright/test v1.44
import { test, expect } from "@playwright/test";
test("a failing panel does not take the shell with it", async ({ page }) => {
// Force the leaf loader to fail without touching the layout's loader.
await page.route("**/api/reports/2026", (r) => r.fulfill({ status: 500, body: "boom" }));
await page.goto("/reports/2026");
// The panel degraded...
await expect(page.getByRole("button", { name: "Try again" })).toBeVisible();
// ...but the navigation is still usable, which is the whole point.
await expect(page.getByRole("navigation")).toBeVisible();
// Navigating away must clear the boundary, not carry the error along.
await page.getByRole("link", { name: "Account" }).click();
await expect(page.getByRole("button", { name: "Try again" })).toHaveCount(0);
await expect(page.locator("[data-route='account']")).toBeVisible();
});
For the manual check, throw from a route component in development and confirm three things in order: the header is still clickable, the fallback offers a retry that actually re-runs the loader, and navigating to another route clears the fallback without a full reload.
Gotchas
- A boundary without a reset key shows a stale error on a healthy page. This is the single most common route-boundary bug, and it only appears when the boundary lives in a shared layout.
- Async errors bypass render-time boundaries. A rejected loader promise must be converted into a thrown value during render, as
renderRouteabove does, or the boundary will never see it. - Retry must re-run the loader, not just re-render. Clearing the caught error and re-rendering cached failure state produces a button that looks broken because nothing changes when it is pressed.
- Do not report the same error on every re-render. Report once per caught error; a boundary that re-renders in a loop will otherwise flood your error sink and mask the real signal.
- The root boundary should offer a full reload. For errors that escape every level, the only reliable recovery is a fresh document — offer it explicitly rather than leaving the user stuck.
FAQ
How many boundaries is too many? One per level that can fail independently is the right granularity. A boundary around every component makes errors invisible and turns a broken screen into a mosaic of small failure messages nobody reports.
Should the fallback show the error message? In development, yes — it saves a trip to the console. In production, show a short human message and a retry, and send the detail to your error sink; error text frequently contains internal identifiers or query fragments.
Does an error boundary change the HTTP status? No. The document was already delivered with a 200. If the failing route also represents a missing resource, pair the boundary with a real status from the edge, as in returning real 404 status codes from a SPA.
What about errors in event handlers? Most framework boundaries do not catch them, because they happen outside the render pass. Wrap risky handlers in try/catch and route the result into the same fallback state deliberately.
How does this interact with offline? A network failure is just an error, but it deserves its own fallback copy: “you appear to be offline” with a retry is far more actionable than a generic message, and it pairs naturally with an offline fallback page.
Related
- Fallback Routing Strategies — the wider set of behaviours for routes that cannot render normally.
- Nested & Layout Routing — the route chain that decides which levels a boundary can protect.
- Configuring SPA Fallback Rewrites on Static Hosts — making sure the document arrives at all before worrying about what it renders.