Errors outside render: the global net and the React 19 reporting pipeline
Handlers, async code and timers never reach boundaries: catch them via window error and unhandledrejection, then route reports through React 19 root callbacks. Dedupe StrictMode doubles, upload source maps, tag releases — and treat hydration mismatches as recoverable, not fatal.
For three weeks, 0.3% of checkout sessions ended with a Pay button that did nothing. The error dashboard was green the whole time — every alert in it was wired through the root boundary’s onError, and the throw lived in an onSubmit handler: a wallet-token parser choking on one bank’s format. Handler errors never reach boundaries, so the dashboard reported a healthy app while conversion quietly bled. An engineer found it only by reproducing locally with that bank’s test card. The team then wired window error and unhandledrejection listeners and got the opposite problem: a flood. Every dev-mode error arrived twice thanks to StrictMode double-invoking renders, hydration mismatches from an A/B test screamed as fatal crashes when React had already recovered by re-rendering on the client, and production stacks were minified gibberish three releases stale because nobody uploaded source maps or tagged the build. The lesson is symmetrical: boundaries alone under-report, a raw global net over-reports — and the pipeline between them is what makes either signal usable.
By the end of this lesson you’ll know why a green error dashboard can coexist with a bleeding conversion rate, and how to wire a pipeline that actually catches everything.
The escape paths: where errors actually leave React
A boundary sees only throws during rendering, lifecycles and constructors. Everything else exits through three doors. Event handlers run as plain function calls on the DOM event path, after commit — an uncaught throw there propagates to window as an error event while the committed UI sits untouched: the silently dead Pay button. Effects with async work: the effect body is synchronous and its throw does surface to the boundary — but the moment you put an async function inside (which is the normal data-fetching shape), a rejection becomes a rejected promise, and a rejected promise nobody awaits fires unhandledrejection, never the boundary. Timers and subscriptions: by the time a setTimeout callback or a WebSocket onmessage throws, the render call stack that mounted them is history; React is not even on the stack.
The global net is two listeners, and the distinction between them carries diagnostic weight:
window.addEventListener("error", (e) => {
report({ kind: "uncaught", error: e.error, stack: e.error?.stack });
});
window.addEventListener("unhandledrejection", (e) => {
report({ kind: "rejection", error: e.reason }); // often no componentStack, sometimes no stack at all
});An error event means synchronous code threw and nobody caught it — handlers, timers, third-party scripts. An unhandledrejection means an async chain leaked — a fetch without a catch, an async effect, a fire-and-forget mutation. Neither carries a componentStack, because React was not involved in the catch: you get the JS stack (which function threw), not the component chain (which part of the UI contained it). That asymmetry is why routing errors into boundaries with showBoundary (lesson 01) is more than aesthetics — it upgrades an anonymous global event into a located, fallback-rendering, componentStack-carrying report.
A data-fetching effect is written as useEffect with an async function inside, and the fetch rejects. Where does the error surface?
React 19 root options: three severities, built in
React 19 turned error reporting into a first-class root contract. createRoot (and hydrateRoot) accept three callbacks, and they partition every React-visible error by what happened to the user:
import { createRoot } from "react-dom/client";
createRoot(container, {
onUncaughtError(error, errorInfo) {
// no boundary caught it — the tree unmounted; the user saw it break
report({ level: "fatal", error, componentStack: errorInfo.componentStack });
},
onCaughtError(error, errorInfo) {
// a boundary handled it — the user saw a designed fallback
report({ level: "handled", error, componentStack: errorInfo.componentStack });
},
onRecoverableError(error, errorInfo) {
// React healed on its own — hydration mismatches land here
report({ level: "recoverable", error: error.cause ?? error, componentStack: errorInfo.componentStack });
},
}).render(<App />);Each receives the error plus errorInfo.componentStack. The split is the severity model you previously had to reverse-engineer: onUncaughtError is the white-screen class — page-level alert material. onCaughtError is the handled class — a boundary rendered its fallback, the user kept a working page; track its rate (a spike means a widget is broken at scale) but do not page on single events. onRecoverableError is the self-healed class: React encountered a problem, recovered automatically, and the user likely saw nothing — for hydration failures the original cause sits in error.cause. Before React 19, caught errors were also duplicate-logged to the console in development, which double-counted anything wired to console capture; React 19 reports each error once, through these hooks. Note what the root options still do not cover: handler throws and leaked rejections never enter React, so the two window listeners stay in the picture — the root callbacks replace guesswork for React-visible errors, not the global net.
Hydration errors: the recoverable class with a real cost
A hydration mismatch — server HTML disagreeing with the first client render — is the canonical onRecoverableError citizen. React discards the damaged server HTML and re-renders the affected tree on the client, so the user sees correct UI, maybe after a flicker. Treating these as fatal (the Hook’s screaming dashboard) is wrong twice: the user was not broken, and the alert noise buries real crashes. But ignoring them is wrong too — each mismatch means a subtree paid for server rendering and then rendered again on the client, and the usual causes (timestamps and locale formatting, Math.random, browser-only branches during render, an A/B test bucketing differently on each side) are deterministic bugs you can fix. The right posture: a separate, non-paging metric with a budget, investigated when the rate moves.
The pipeline: dedupe, symbolicate, tag — or drown
When you see a tracker flooded with duplicates and minified gibberish, you’re not looking at a capture problem — you’re looking at a pipeline problem. Raw capture is the easy half. The Hook’s flood came from the pipeline’s missing stages:
Dedupe. The same failure arrives through multiple doors: a boundary’s onError plus onCaughtError if you wired both; an error captured locally and then re-thrown; in development, StrictMode’s deliberate double-invoking of render functions (and double-running of effects) manufactures doubles of any render-path error — which is a feature for catching impure code, and a lie in your metrics if dev events reach the production tracker. Fingerprint events — message plus top stack frames plus release — count occurrences, and drop dev-mode noise at the source.
Symbolicate. Production stacks are minified: t.map is not a function at a.render (main.3f2c1.js:1:48212) locates nothing, and React’s own production error messages are compressed to numbered codes with a decoder URL. Without uploading source maps per build, every report is archaeology. The componentStack (from boundary or root callbacks) survives minification better — component names — but component names themselves can be mangled unless you preserve them or map them back.
Tag the release. A stack is only meaningful against the exact build that produced it. Tag every event with the release that shipped it, and the dashboard gains its two most useful queries: “did this start with the deploy?” and “is it gone after the rollback?” — the difference between debugging and guessing.
After enabling StrictMode, the team sees render-path errors counted twice in their dev error tracker and concludes StrictMode is buggy. What is actually happening?
▸Why this works
Why does React not just forward handler and promise errors into boundaries itself? Because by the time they fire, React has nothing to repair and no context to attribute. A boundary’s mandate is tree integrity: a render throw means the tree under construction is unusable, so React must unwind to a fallback. A handler throw happens with a perfectly consistent committed tree — unmounting anything would destroy correct UI on a hunch. And attribution is genuinely ambiguous: a setTimeout scheduled by a component that has since unmounted belongs to which boundary? A promise chain passed across three modules? The platform already defines homes for these — the error and unhandledrejection events — so React leaves ownership with you, and showBoundary exists precisely to let you assign an error to a tree location when you, unlike React, know where it belongs.
- 01Map each error origin to its capture channel and say which channels carry componentStack.
- 02Name the three pipeline stages between capture and dashboard, and the failure mode each prevents.
Boundaries cover the render path; everything else leaves React through doors of its own. Event handlers and timer callbacks throw after commit, with a consistent tree on screen, so React leaves the UI alone and the exception reaches the window error event — the silently dead button. Async work inside effects becomes promises nobody awaits, and their rejections fire unhandledrejection with the error in e.reason. Neither channel knows componentStack, because React was not in the catch — which is the deeper argument for catching in handlers and routing into boundaries with showBoundary when a located fallback matters. React 19 makes the React-visible side a root contract: onUncaughtError is the white-screen severity that pages someone, onCaughtError is the handled severity whose rate you watch, and onRecoverableError is the self-healed class — hydration mismatches land there with the cause in error.cause, deserving a budgeted metric rather than alerts, because each one is a deterministic SSR-divergence bug that costs a client re-render but never broke the user. Between capture and dashboard sits the pipeline that decides whether any of this is usable: fingerprint dedupe collapses the same failure arriving through multiple doors and absorbs StrictMode’s deliberate dev-only double-invocations; uploaded source maps turn minified frames and numbered React error codes back into code; release tags make the dashboard answer whether the deploy caused it and whether the rollback cured it. Under-wire the capture and the dashboard lies green while checkout bleeds; skip the pipeline and it screams until nobody listens — the craft is in having all four origins wired and all three stages between them and the pager. Now when you see a green dashboard alongside a dead button, you’ll know to check window error and unhandledrejection first — and to ask whether the pipeline’s three stages are actually in place.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.