Most React applications we review have error boundaries. Fewer have a useful record of what those boundaries caught. The boundary renders "Something went wrong", the user reloads, and the only trace left is a support ticket written three days later by someone who cannot reproduce it. The boundary did its job — the app did not go blank — but the bug is now invisible.
This post covers the second half: what to do after the catch. Where boundaries belong, how React 19's root-level error handlers change the wiring, and what has to be true of your build before a stack trace in a dashboard means anything.
Place boundaries where recovery is possible
A boundary is only worth having where there is a smaller thing to render than the whole page. That gives three useful positions, and one common mistake.
At the route level, a boundary keeps the shell, the navigation, and the user's session while one screen fails. In a Next.js App Router app this is error.tsx in the route segment; in React Router v7 it is ErrorBoundary on the route. In both, failure is scoped to the part of the tree that failed, and the user can navigate away instead of reloading.
Around independent widgets — a chart, a third-party embed, a recommendations rail — a boundary turns a crash into a missing panel. This is where boundaries pay the most, because these are the components most likely to receive data shapes nobody anticipated.
At the root, as a last resort, to replace the blank page with something that offers a reload and carries a reference ID.
The mistake is a single root boundary and nothing else. It converts every error anywhere into a full-page failure, which is barely better than the white screen and much harder to diagnose, because the component that failed is now several frames up a stack you cannot read.
What boundaries do not catch is worth repeating, because teams are surprised by it in production: event handlers, setTimeout and other async callbacks, server-side rendering errors, and errors thrown inside the boundary itself. A promise rejection in an onClick handler never reaches a boundary. Those need window.onerror and window.onunhandledrejection, which your reporting SDK installs — but only if you let it, and only if you do not swallow the rejection in a catch that logs nothing.
React 19 moved the reporting hook
Before React 19, the way to report a caught error was componentDidCatch in whichever boundary class you wrote or imported, which meant every boundary had to remember to call the reporter. React 19 added error handling options on the root, so reporting becomes one piece of wiring instead of a convention:
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'), {
onCaughtError: (error, errorInfo) => {
report(error, { kind: 'caught', componentStack: errorInfo.componentStack });
},
onUncaughtError: (error, errorInfo) => {
report(error, { kind: 'uncaught', componentStack: errorInfo.componentStack });
},
onRecoverableError: (error, errorInfo) => {
report(error, { kind: 'recoverable', componentStack: errorInfo.componentStack });
},
});
The three are genuinely different signals and should not be merged into one alert rule.
onCaughtError fires when a boundary caught the error. The user saw a fallback. This is a bug with a known blast radius.
onUncaughtError fires when nothing caught it. The tree unmounted; the user saw an empty page. Every one of these is either a real crash or a missing boundary, and the fix is often the boundary.
onRecoverableError fires when React recovered on its own — most often a hydration mismatch that React patched by re-rendering on the client. Nothing broke visibly, which is exactly why these go unnoticed, and they are a reliable early warning of the server/client divergence we wrote about in the hydration post. Route them to a dashboard, not to a pager.
For server rendering, renderToPipeableStream takes onError, and hydrateRoot takes the same three options as createRoot. If you render on the server and only instrument the client, you are blind to the half of your errors that happen before the HTML is sent.
One practical note: React 19 stopped re-throwing caught errors to window.onerror by default. If your reporting SDK was picking up boundary-caught errors through the global handler, that path is gone, and the volume will quietly drop to zero after the upgrade. Check your error dashboard the week you ship React 19 — a sudden improvement in error counts is usually not an improvement. Reporting SDKs have adapted, but the version matters; confirm it rather than assume it.
A stack trace without source maps is not a report
This is the step most often skipped, and it makes everything above worthless. Minified production output turns a stack into t is not a function at a.js:1:48213. You cannot act on that.
Generate source maps in the production build, upload them to your error service as a build step, and do not serve them publicly — uploading and not deploying is the posture you want for a private application. In Vite that is build.sourcemap: true plus an upload in CI; Next.js has productionBrowserSourceMaps and the official SDK plugins handle the upload. Tie the upload to a release identifier (commit SHA) and set the same identifier in the SDK at runtime, or the service cannot match a map to the bundle that produced the error.
Then verify it. Throw a deliberate error in a staging build, open the report, and confirm you see a file name, a line, and your own source. Teams routinely discover during an incident that their maps have been mismatched for months.
While you are there, add the component stack from errorInfo to the report payload. A minified JS stack tells you which function threw; the component stack tells you which ProductRow in which ProductTable, which is usually the faster route to a reproduction.
Make the reports actionable
Two failure modes follow a successful instrumentation project. Either nobody looks at the dashboard, or the dashboard is unreadable. Both are grouping and context problems.
Add the context that lets you decide whether to care: release SHA, route, and whether the error was caught or uncaught. Attach a user-visible reference ID — generate one in the fallback UI, show it, and send it with the report, so a support ticket and an error group can be joined without guessing.
Filter aggressively. Browser extensions, third-party scripts, and cancelled network requests generate noise that will bury real crashes within a week. ResizeObserver loop completed with undelivered notifications is not your bug. Denylist by error message and by originating script URL, and review the denylist occasionally so it does not silently grow to cover something real.
Then pick one number to watch — crash-free sessions is the usual choice — and put a threshold on it. Alert on new error groups and on regressions in that number, not on volume, which tracks traffic more than quality.
Fallbacks that do not lose the user's work
The fallback is product surface, not a 500 page. Three things make a difference: offer a retry that resets the boundary and re-runs the failed render rather than reloading the document; preserve what was typed, because a form that clears on error turns a recoverable bug into an abandoned session; and keep the message specific enough to be honest without naming internals. "We couldn't load your invoices" plus a reference ID is better than "Something went wrong" and far better than a raw stack trace.
If you use react-error-boundary, its resetKeys and onReset give you the retry path without writing the class yourself. In App Router, error.tsx receives a reset() function for the same purpose.
Where to start this week
If you have boundaries but no reporting: wire the three root handlers, ship source maps with a release identifier, and verify with a deliberate error. That is an afternoon.
If you have reporting but ignore it: spend an hour on the denylist and set a crash-free-sessions threshold. An alert nobody trusts is worse than no alert.
If you are on React 18 and upgrading: do the handler wiring as part of the upgrade, not after, and check error volume the week you ship. The quiet disappearance of boundary-caught errors from window.onerror has fooled teams into thinking a release went better than it did.
The standard to hold yourself to is simple. When an error fires in production, you should be able to name the release, the route, the component, and the line — without asking the user to reproduce it.