Error boundaries and crash recovery
Error boundaries catch render errors in the subtree below them and show a fallback. Place one at the app root (last line of defense), one per route, and one per significant widget (chart, table, embed) so one crash doesn't take down the page. Pair with global window error handlers for unhandled promise rejections, and a logger (Sentry) to capture stack + componentStack + user context.
An error boundary is a React class component (or the react-error-boundary wrapper) that catches render-phase errors in its subtree via getDerivedStateFromError + componentDidCatch. Without one, an uncaught error in render unmounts the entire React tree.
What boundaries catch
✓ Errors thrown during rendering, lifecycle methods, and constructors of child components.
✗ Event handlers (catch with try/catch). ✗ Async code (promises) — boundaries don't see these. ✗ Errors in the boundary's own render method (caught by the next boundary up). ✗ Server-side rendering errors (handled separately).
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<div role="alert">
<p>Something went wrong: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
onError={(error, info) => reportToSentry(error, info)}
resetKeys={[currentRoute]} // auto-reset when this changes
>
<App />
</ErrorBoundary>The react-error-boundary library is the standard wrapper in 2026 — gives you the hook (useErrorBoundary) and the reset semantics without writing a class.
Where to place them
A single root-level boundary is not enough — a chart crash shouldn't blank the whole app.
<RootBoundary> ← last line of defense
<Router>
<RouteBoundary> ← reset on route change
<Page>
<WidgetBoundary> ← isolate the chart
<Chart />
</WidgetBoundary>
<WidgetBoundary>
<Comments />
</WidgetBoundary>
</Page>
</RouteBoundary>
</Router>
</RootBoundary>Three tiers.
- Root: catches everything else; shows a full-page fallback. Logs to error tracker.
- Route: catches errors per page; shows a "this section crashed" with a retry. Reset on navigation.
- Widget: isolates risky subtrees (third-party embeds, charts that may receive bad data, user-generated content). Shows a small placeholder.
Catching what boundaries miss
Async errors.
useEffect(() => {
load().catch(reportError);
}, []);
// or surface via state and re-throw to trigger the boundary:
const { showBoundary } = useErrorBoundary();
useEffect(() => {
load().catch(showBoundary);
}, []);Event handler errors.
try { onClick(); } catch (e) { showBoundary(e); }Or wrap handlers in a utility.
Global window listeners (last-resort capture).
useEffect(() => {
const onError = (e: ErrorEvent) => reportError(e.error);
const onRejection = (e: PromiseRejectionEvent) => reportError(e.reason);
window.addEventListener("error", onError);
window.addEventListener("unhandledrejection", onRejection);
return () => {
window.removeEventListener("error", onError);
window.removeEventListener("unhandledrejection", onRejection);
};
}, []);These catch stack overflows, errors outside the React tree, and unhandled rejections nothing else caught.
What to report
Sentry / Datadog / etc. should receive:
- The error (
Errorobject with stack). - React's
componentStack(which components led to this point). - User context (id, plan, locale).
- Session id and the last few user actions (breadcrumb trail).
- Build version / commit (to map to source maps).
- Browser, viewport size.
Most error trackers wrap React for you — Sentry's Sentry.ErrorBoundary is a drop-in.
Recovery UX
1. Retry. resetErrorBoundary() re-mounts the children. Useful when the cause was transient (network blip causing a failed Suspense load).
2. Auto-reset on route change. resetKeys={[pathname]} — boundary clears when navigating away.
3. Don't loop. If the same error keeps happening, retry indefinitely is worse than a clear "we're sorry, contact support" message. Limit retries.
4. Distinguish kinds. Network error vs validation error vs developer bug should look different. Wrap the boundary's fallback in a small classifier.
The Suspense interaction
A thrown Promise (Suspense) is NOT an error — it suspends. An error boundary placed above a Suspense boundary catches the actual errors; the Suspense handles the loading state. Standard layout:
<ErrorBoundary fallback={<ErrorPage />}>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>Chunk-load errors after deploy
After a deploy, an old browser may try to load a stale chunk URL — 404 → error boundary. Add a special-case:
function isChunkError(e: Error) {
return /ChunkLoadError|Loading chunk \d+ failed/.test(e.message);
}
onError={(e) => {
if (isChunkError(e)) {
window.location.reload(); // fresh HTML → fresh hashed chunk URLs
} else {
reportToSentry(e);
}
}}What boundaries don't fix
A boundary masks the symptom; the fix is in the code that threw. Boundaries are a backstop for unknown unknowns, not an excuse for sloppy data validation. Track boundary triggers as a metric and treat new spikes as bugs.
Senior framing
The interviewer expects:
- Tiered placement (root / route / widget).
- Catching async + event-handler errors the boundary doesn't see.
- Reporting with componentStack + user context.
- Chunk-error reload pattern.
- Suspense interaction — errors vs loading.
- Recovery UX — retry, but bounded.
The "we have one error boundary at the root" answer is junior; the architecture above is senior.
Follow-up questions
- •Why do event handlers not trigger error boundaries?
- •How does the Suspense + ErrorBoundary stack work together?
- •What's the right strategy for chunk-load errors after deploy?
- •When does `resetKeys` not reset what you expect?
Common mistakes
- •Only one boundary at the root — every crash blanks the whole app.
- •Forgetting to report errors to a tracker.
- •Auto-retry without bounds, causing infinite loops.
- •Not capturing unhandled promise rejections at the window level.
Performance considerations
- •Boundaries themselves are essentially free at runtime.
- •Sentry's componentStack collection is fast; full breadcrumb trails can be heavier.
Edge cases
- •Errors thrown inside a Suspense fallback are not caught by an ancestor boundary in the same render — restructure.
- •Source maps must be uploaded for stack traces to be readable in Sentry.
- •Server-rendered errors require a separate strategy (renderToPipeableStream onError).
Real-world examples
- •Every modern React app has tiered boundaries + Sentry. The pattern is standard.