How to capture unhandled promise rejections in modern browsers
`unhandledrejection` is necessary but not sufficient. The async stacks, polyfill quirks, and framework integrations you also need.
A user clicks "Purchase." The spinner spins forever. Your error dashboard stays perfectly, suspiciously clean. The culprit is a fetch() that failed inside an async function with no catch block, and it vanished into the unhandled-rejection void—a place window.onerror never looks. In a modern web app where nearly everything is a Promise, relying on window.onerror alone is like trying to catch rain with a sieve.
Unhandled promise rejections are the dark matter of JavaScript errors. They make up a large share of real frontend failures, they fail silently by design, and most error setups capture them poorly or not at all. This post covers how to capture them correctly, why their stack traces tend to evaporate, and how to turn a useless "rejection: undefined" into something a developer can actually fix.
Why window.onerror is no longer enough
The web's original error model was synchronous. Code ran top to bottom, and when something threw, window.onerror caught it on the way up the call stack. That model made sense in 2010. It does not describe how applications run in 2026, where data fetching, dynamic imports, timers, and effects are all Promise-based and resolve after the synchronous stack has already unwound.
Errors vs. rejections
A thrown Error and a rejected Promise are fundamentally different events. A throw propagates synchronously and triggers the error event. A rejection is an asynchronous state transition on a Promise object—it triggers the separate unhandledrejection event, but only if nothing ever attaches a handler to that Promise. window.onerror simply does not fire for rejections.
// CAPTURED by window.onerror — synchronous throw
function syncBoom() {
throw new Error("This one shows up");
}
// NOT captured by window.onerror — async rejection
async function asyncBoom() {
const res = await fetch("/api/charge"); // network fails
return res.json(); // never reached
}
asyncBoom(); // no .catch() -> silent unhandled rejectionWhy Promises fail silently by design
A Promise is allowed to reject and have nobody listening. The language doesn't consider that an error until the microtask queue drains and the runtime notices the rejection was never handled—and even then, the only consequence in a browser is a console warning and the unhandledrejection event. The user-facing symptom is a stuck UI, not a crash. That's exactly why these bugs are so corrosive: nothing visibly breaks, so nothing gets reported.
Implementing the unhandledrejection event
The modern standard is a global listener on the unhandledrejection event. It hands you a PromiseRejectionEvent with two important properties: .promise (the Promise that rejected) and .reason (whatever it rejected with).
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
// .reason is NOT always an Error. It can be a string,
// a number, a plain object, or even undefined.
const normalized =
reason instanceof Error
? { message: reason.message, stack: reason.stack }
: { message: String(reason), stack: undefined };
reportToTracker({
type: "unhandledrejection",
...normalized,
timestamp: Date.now(),
});
// Optional: stop the default console warning
event.preventDefault();
});The single most common mistake here is assuming event.reason is an Error. People reject with strings (Promise.reject("nope")), with API response objects, or with nothing at all. If you blindly read reason.stack, you'll throw inside your own error handler. Always normalize first.
preventDefault and console clutter
Calling event.preventDefault() suppresses the browser's default unhandled-rejection console warning. Use it carefully: during development the console warning is genuinely useful, so you may only want to suppress it in production once you're confident your reporting pipeline is solid.
The stack-trace problem: async stack tagging
Capturing the rejection is the easy half. The hard half is that the stack trace you get is frequently useless—it points at the microtask runner or the internals of your fetch wrapper, not at the line of your code that initiated the doomed operation.
How V8 handles async stacks
When a Promise rejects, the synchronous call stack that created it is long gone—it unwound the moment you hit the first await. Naively, the engine can only show you the stack from the point of rejection forward. V8's zero-cost async stack traces stitch the logical "async" stack back together so you can see the chain of awaits that led here, but this reconstruction works far better in some code shapes than others.
Why await beats .then()
Code written with await preserves async context dramatically better than the equivalent .then() chains. With await, the engine has clear continuation points to tag; with long .then() chains, the connective tissue between callbacks is thinner and the resulting trace is shallower. If you want debuggable rejections, prefer async/await over Promise chaining—it's not just stylistic.
// Shallow trace: the .then() callback's origin is murky
loadUser().then(u => render(u)).then(saveAnalytics);
// Deeper trace: await gives V8 clean continuation points
async function go() {
const u = await loadUser();
render(u);
await saveAnalytics();
}Once you have a stack, it'll still be minified in production. Pair this with proper source maps—see why your SDK isn't capturing source-mapped traces—or paste the async stack into the deminifier to read it.
Framework-specific gotchas
Frameworks add a layer of confusion because developers assume the framework's error handling covers rejections. It usually doesn't.
React error boundaries don't catch rejections
This trips up nearly everyone. React Error Boundaries catch errors thrown during rendering, in lifecycle methods, and in constructors. They do not catch errors in event handlers, in setTimeout, or—critically—in async code. A rejected Promise inside a useEffect sails straight past every boundary you've set up.
function Profile({ id }) {
useEffect(() => {
// This rejection will NOT hit any Error Boundary.
fetch("/api/user/" + id).then(r => r.json()).then(setUser);
// You must catch it yourself:
// .catch(err => Sentry.captureException(err));
}, [id]);
}The practical rule: every async call in a useEffect (or any handler) needs an explicit .catch that forwards to your tracker, or you rely entirely on the global unhandledrejection listener as the safety net.
Next.js navigation patterns
Next.js complicates this further because notFound() and redirect() work by throwing control-flow signals that the framework catches internally. A naive global handler can mistake these for real errors. Filter them out in your reporting logic so legitimate navigation doesn't pollute your dashboard. For the full picture, see error tracking in Next.js 15.
Polyfill quirks and legacy support
The unhandledrejection event only fires for native Promises. If any layer of your stack uses a Promise polyfill or library, the event may never reach the browser at all.
- Bluebird: ships its own global rejection mechanism (
Promise.onPossiblyUnhandledRejection) rather than firing the native browser event. If Bluebird is in your bundle, wire up its hook in addition to the native listener. - Zone.js (Angular): monkey-patches async APIs to power change detection, which can intercept rejections before they bubble to
window. Angular's ownErrorHandleris the right integration point there. - Safari: was historically late to implement
unhandledrejectionand shipped subtle timing bugs in early versions. Modern Safari supports it, but if you support older iOS, treat the event as best-effort rather than guaranteed.
Turning rejections into actionable data
A captured rejection is only valuable if it helps someone fix the bug. Three things turn a bare event into a diagnosis.
Scrub PII before sending
Rejection reasons frequently contain request payloads, URLs with tokens, or form data. Scrub it before it leaves the browser. GlitchReplay performs PII scrubbing at the edge as a default, but stripping obvious secrets client-side in your normalizer is a sensible first line of defense.
Attach breadcrumbs
The question that solves most silent rejections is "what happened right before?" Breadcrumbs—the last few clicks, navigations, and network calls—give the rejection a narrative. A bare TypeError: Failed to fetch is noise; the same error preceded by "clicked Pay → POST /charge → offline" is a diagnosis.
Why session replay closes the loop
For genuinely silent rejections—the spinner that spins forever with no crash—replay is often the only way to understand the failure, because there's no visible symptom to reproduce. Seeing the user's actual DOM state at the moment of rejection turns "cannot reproduce" into "oh, the button was disabled." That's the core argument in session replay vs. logging, and you can tie a rejection directly to its replay via the shared event ID.
Server-side: Node.js vs. the browser
The same concept exists on the backend, with a sharper consequence. Node exposes process.on('unhandledRejection'), the analog of the browser event. The difference is severity: under Node's default behavior, an unhandled rejection can terminate the entire process, taking down every other in-flight request with it. A browser merely logs a warning and moves on. That asymmetry is why server-side rejection handling isn't optional—a single missed catch can crash your service. Get started wiring this up across both runtimes in the getting-started guide.
Stop letting promises fail in the dark. Add the global unhandledrejection listener, normalize the reason, prefer await for readable async stacks, and forward everything—with breadcrumbs and replay—to a tracker that won't charge you a premium for catching the 40% of bugs your old setup was silently dropping.
GlitchReplay is Sentry-SDK compatible, includes session replay and security signals, and never charges per event. Free to start, five minutes to first event.