Handling Unhandled Promise Rejections in Modern JS
When a Promise rejects and no .catch() or try/catch block is wired up in time, the runtime does not silently swallow the failure — it routes it to a dedicated global event. In browsers that event is unhandledrejection; in Node.js it is the 'unhandledRejection' process event. Without handlers for both, async failures disappear from your observability stack entirely: no stack trace, no alert, no signal to on-call. This guide assumes you are comfortable with Promise chains and async/await syntax, and that you have already read the Core JavaScript Error Handling & Boundaries overview. For synchronous global errors consider the companion guide on Mastering window.onerror and Global Event Listeners, and for the Node.js-specific distinction between uncaughtException and unhandledRejection see Node.js uncaughtException vs unhandledRejection.
By the end of this guide you will be able to:
- Attach
unhandledrejectionlisteners before framework hydration so that zero async failures escape capture during module loading. - Implement a Node.js graceful-shutdown pattern that drains connections before exiting on an unhandled rejection.
- Normalize non-
Errorrejection values so that every captured payload carries a usable stack trace. - Understand microtask timing well enough to predict exactly when the event fires and when a late
.catch()is too late. - Write automated tests that assert your global rejection handler actually fires.
Problem Framing & Symptom Identification
The deceptive thing about unhandled promise rejections is that they can fail silently in ways that synchronous errors never do. A thrown TypeError stops execution and dumps a stack trace to the console. A rejected Promise with no .catch() does something more insidious: it waits until the current microtask queue drains, checks whether a rejection handler has been attached, and only then fires the global event — often long after the operation that caused the problem has returned.
Common symptoms include:
UnhandledPromiseRejectionWarningprinted to stderr in Node.js (v14 and earlier) or the process exits with code 1 (v15+).- In browsers, a red
Uncaught (in promise)line in DevTools console with no stack pointing to your source code — just to a minified bundle. - Metrics dashboards that show error-rate spikes at deployment time followed by gradual recovery, because the first async operation after a bad deploy rejects without a handler.
- Intermittent test failures where a test suite passes locally but fails in CI, because CI’s newer Node.js version terminates the process on an unhandled rejection that the local version merely warns about.
The underlying mechanism is precise. When a Promise transitions to the rejected state, V8 places a microtask on the queue to check whether a reaction (.then, .catch, or .finally) has been registered. If the queue drains and no reaction was registered synchronously, the runtime fires unhandledrejection (browser) or emits the 'unhandledRejection' process event (Node.js). The key word is synchronously: a .catch() added in a setTimeout callback or after an await expression is too late.
Prerequisites & Environment Setup
This guide targets browsers supporting the unhandledrejection event (Chrome 49+, Firefox 69+, Safari 11+) and Node.js 14 or later. The examples use native Promise and async/await with no transpilation. For observability integration the code references a generic telemetry object with a capture(error, metadata) method — substitute your SDK of choice.
Node.js: install and pin a version
# Confirm you are running v14 or later
node --version
# For new projects, create a .nvmrc to lock the runtime
echo "20" > .nvmrc
nvm use
Node.js: enable async stack traces (v12.12+)
# Pass this flag at startup for richer async frames in development
node --enable-source-maps --stack-trace-limit=50 server.js
Browser: no install required — verify event support
// Feature-detect before relying on the event
const supportsUnhandledRejection = 'onunhandledrejection' in window;
Test harness: Jest with fake timers
# Installs Jest with the modern fake timer implementation
npm install --save-dev jest@29
Step-by-Step Implementation
Step 1 — Attach the browser listener before framework hydration
The listener must be registered before any framework or bundler code runs, which means before the main bundle is parsed. Place it in an inline <script> tag in the <head> of your HTML, not in a deferred or async script.
// Inline script in <head> — runs before any framework bundle
window.addEventListener('unhandledrejection', function (event) {
// Suppress the default "Uncaught (in promise)" console error
event.preventDefault();
// Normalize: reason may be a string, null, or any object
var reason = event.reason instanceof Error
? event.reason
: new Error(String(event.reason ?? 'Unknown rejection'));
// Attach the original reason as a property for downstream inspection
reason.originalReason = event.reason;
// Forward to telemetry — telemetry must be pre-initialized or queue internally
if (window.__telemetry) {
window.__telemetry.capture(reason, {
type: 'unhandledRejection',
promise: event.promise,
});
}
});
Why normalize the reason to an Error instance: promises can be rejected with any value — a string like 'Network error', the number 404, null, or a plain object. Only Error instances carry a .stack property. When you coerce to new Error(String(reason)), the resulting stack trace points to the normalization site inside your handler, which is far more useful than an empty .stack field. Store the original value on reason.originalReason so downstream code can still inspect it.
Step 2 — Track the rejectionhandled event for deduplication
The browser fires rejectionhandled when a .catch() is added to a previously-unhandled promise. This matters for deduplication: if your global handler logs the rejection and then user code adds a .catch() a macrotask later, you will have a false positive in your error tracker.
// Keep a weak reference map so we can cancel in-flight telemetry
const pendingRejections = new WeakMap();
window.addEventListener('unhandledrejection', function (event) {
event.preventDefault();
const reason = event.reason instanceof Error
? event.reason
: new Error(String(event.reason ?? 'Unknown rejection'));
// Store a cancellation token keyed to the promise reference
const token = window.__telemetry?.captureDeferred(reason, { type: 'unhandledRejection' });
if (token) pendingRejections.set(event.promise, token);
});
window.addEventListener('rejectionhandled', function (event) {
// If a .catch() was added later, cancel the pending telemetry report
const token = pendingRejections.get(event.promise);
if (token) {
window.__telemetry?.cancel(token);
pendingRejections.delete(event.promise);
}
});
Step 3 — Implement the Node.js process handler with graceful shutdown
// server.js — register before any async operations start
const server = require('./http-server'); // your http.Server instance
process.on('unhandledRejection', function (reason, promise) {
// Log synchronously — do not await anything here
process.stderr.write(
'[FATAL] Unhandled Rejection at: ' + String(promise) +
'\nReason: ' + (reason?.stack || reason) + '\n'
);
// Attempt synchronous telemetry flush if the SDK supports it
if (global.__telemetry?.flushSync) {
global.__telemetry.flushSync();
}
if (process.env.NODE_ENV === 'production') {
// Stop accepting new connections and give existing ones 5 s to finish
server.close(function () {
process.exit(1); // clean exit after drain
});
// Hard timeout: if server.close() stalls, force exit
setTimeout(function () { process.exit(1); }, 5000).unref();
// .unref() prevents the timeout from keeping the event loop alive
}
});
The synchronous-only constraint is critical. Node.js does not await the callback: if you write async function (reason, promise) { await telemetry.send(reason); }, the await returns a promise that Node.js ignores entirely. The send call may never complete. Use synchronous writes (process.stderr.write, fs.writeFileSync) or SDK methods that internally buffer and flush synchronously.
Step 4 — Control Node.js behaviour with --unhandled-rejections
Node.js exposes a command-line flag to configure the default behaviour before any process.on handler is registered. The available values and their effects:
| Flag value | Node.js behaviour |
|---|---|
throw |
Converts unhandled rejections to uncaught exceptions (default v15+) |
strict |
Same as throw, but also ignores any process.on('unhandledRejection') handler |
warn |
Prints a deprecation warning to stderr, process continues (v14 default) |
warn-with-error-code |
Warns and exits with code 1 after the event loop drains |
none |
Silently ignores all unhandled rejections — never use in production |
# Enforce strict termination in CI even on older Node.js versions
node --unhandled-rejections=strict server.js
Step 5 — Prevent rejections with async/await and try/catch
A try/catch block wrapping an await expression is the most reliable way to prevent a rejection from going global. Understanding why helps you write more defensive code:
async function fetchUserData(userId) {
try {
// Without try/catch, a network failure here becomes an unhandled rejection
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
// Throwing inside try/catch keeps the rejection local
throw new Error(`HTTP ${response.status} fetching user ${userId}`);
}
return await response.json();
} catch (err) {
// Re-throw with additional context rather than swallowing the error
err.userId = userId;
throw err; // caller's try/catch or .catch() receives this
}
}
When await is used without try/catch, the rejection propagates up the async call stack. If the top-level async function is not itself awaited (for example it is called from a synchronous event handler), the rejection has nowhere to go and the global handler fires.
Step 6 — Use Promise.allSettled for concurrent work
Promise.all short-circuits on the first rejection, meaning every other in-flight promise is abandoned and its rejection may become unhandled:
// Unsafe: if tasks[1] rejects, tasks[0] and tasks[2] rejections may go unhandled
const results = await Promise.all(tasks.map(t => t.run()));
// Safe: waits for all, returns { status, value/reason } for each
const results = await Promise.allSettled(tasks.map(t => t.run()));
for (const result of results) {
if (result.status === 'rejected') {
// Handle each rejection individually
telemetry.capture(result.reason, { type: 'batchTask' });
}
}
Step 7 — Handle AbortController rejections without noise
Fetch requests cancelled via AbortController reject with a DOMException named 'AbortError'. If you cancel requests on route changes or component unmounts, these rejections will flood your global handler unless you filter them:
window.addEventListener('unhandledrejection', function (event) {
const reason = event.reason;
// Suppress intentional aborts — they are not errors
if (reason?.name === 'AbortError') {
event.preventDefault();
return; // do not forward to telemetry
}
event.preventDefault();
const err = reason instanceof Error
? reason
: new Error(String(reason ?? 'Unknown rejection'));
window.__telemetry?.capture(err, { type: 'unhandledRejection' });
});
Production Telemetry Integration
Forwarding a captured rejection to an observability SDK requires three things: a normalized Error object, a deobfuscated stack trace, and structured metadata that lets you group and triage alerts.
Stack trace depth. V8 caps stack frames at 10 by default. Increase this before your application boots:
// Increase frame limit early in your entry point
Error.stackTraceLimit = 50;
Async stack traces. Modern V8 (Node.js 12+, Chrome 73+) supports --async-stack-traces by default. When enabled, await expressions annotate the captured stack with async frame markers, making it possible to trace a rejection back through multiple async functions. In Node.js you can also pass --enable-source-maps to have stack frames automatically translated through your source maps at the process level — see the guide to source map generation and stack trace debugging for details on generating and deploying source maps that survive CDN deployment.
Structured payload for forwarding:
function buildRejectionPayload(reason, meta = {}) {
const err = reason instanceof Error
? reason
: new Error(String(reason ?? 'Unknown rejection'));
return {
message: err.message,
stack: err.stack, // raw stack — SDK deobfuscates server-side
name: err.name,
// Avoid circular refs: only copy own, serialisable properties
extra: Object.fromEntries(
Object.entries(err)
.filter(([, v]) => typeof v !== 'function' && typeof v !== 'object')
),
...meta,
timestamp: Date.now(),
environment: process.env.NODE_ENV ?? 'unknown',
};
}
In the browser, call your SDK’s captureException or equivalent inside the unhandledrejection handler and pass the normalized Error plus the payload above as context. Most SDKs also accept a hint object where you can pass event.promise for additional reference.
Verification & Testing
Manual verification in the browser
Open DevTools, paste this in the console, and confirm that your telemetry SDK records an event:
// This promise rejects in the next microtask with no handler attached
Promise.reject(new Error('Manual test rejection'));
Automated Jest tests
Jest’s unhandledRejection hook lets you assert that your global handler fires. Use process.emit to simulate the event in Node.js tests:
// __tests__/rejection-handler.test.js
const { captureRejection } = require('../src/rejection-handler');
describe('unhandledRejection handler', () => {
let spy;
beforeEach(() => {
spy = jest.spyOn(global.__telemetry, 'capture').mockImplementation(() => {});
});
afterEach(() => {
spy.mockRestore();
});
it('forwards Error instances to telemetry', () => {
const err = new Error('test');
// Simulate the process event synchronously
process.emit('unhandledRejection', err, Promise.reject(err).catch(() => {}));
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ message: 'test' }),
expect.objectContaining({ type: 'unhandledRejection' })
);
});
it('normalizes non-Error string rejections', () => {
process.emit('unhandledRejection', 'network timeout', Promise.resolve());
expect(spy).toHaveBeenCalledWith(
expect.objectContaining({ message: 'network timeout' }),
expect.any(Object)
);
});
});
Verifying the browser event with JSDOM
// Jest with jsdom environment
it('browser handler calls telemetry on unhandled rejection', async () => {
const capture = jest.fn();
window.__telemetry = { capture };
// Trigger the event manually (JSDOM does not synthesize it automatically)
const ev = new PromiseRejectionEvent('unhandledrejection', {
promise: Promise.resolve(),
reason: new Error('jsdom test'),
cancelable: true,
});
window.dispatchEvent(ev);
expect(capture).toHaveBeenCalledWith(
expect.objectContaining({ message: 'jsdom test' }),
expect.objectContaining({ type: 'unhandledRejection' })
);
});
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
unhandledrejection fires even though .catch() is present |
.catch() was added in a setTimeout or after an await — too late for the microtask check |
Move .catch() to be chained synchronously on the Promise return value |
Node.js process exits with code 1 despite process.on('unhandledRejection') handler |
--unhandled-rejections=strict is set, which bypasses all handlers |
Remove strict or switch to throw and let your handler intercept the resulting uncaughtException |
| AbortError floods telemetry after route navigation | Fetch cancellations via AbortController are intentional rejections, not bugs |
Check reason.name === 'AbortError' early in the handler and call event.preventDefault() without forwarding |
Handler fires but stack trace is [object Object] |
Promise was rejected with a plain object rather than an Error |
Normalize with new Error(JSON.stringify(reason)) and attach the original value as a property |
| Async telemetry call inside Node.js handler never completes | process.on('unhandledRejection') callbacks are synchronous; await inside them is ignored |
Use flushSync APIs or synchronous writes; queue async telemetry via a fire-and-forget non-blocking pattern before calling process.exit |
rejectionhandled never fires even after .catch() is added |
The promise reference was garbage-collected before the .catch() call |
Keep a live reference to the promise until the .catch() is attached |
FAQ
Does calling event.preventDefault() in the browser suppress all console output?
Yes, on browsers that support it (Chrome and Firefox). The default behaviour — printing Uncaught (in promise) Error: ... to the DevTools console — is suppressed. Safari does not honour preventDefault() for this event and will still print to the console regardless.
Why does my Node.js v14 app warn instead of crashing, but v15 crashes?
Node.js changed the default --unhandled-rejections mode from warn (v14) to throw (v15+). The throw mode converts unhandled rejections into uncaught exceptions, which terminates the process. To get consistent behaviour across versions, always set --unhandled-rejections=throw explicitly in your start script or package.json "scripts" block.
Can I use an async function as the process.on('unhandledRejection') callback?
You can register one, but Node.js will not await it. Any code after the first await inside the callback may not execute before process.exit() is called. All I/O in the handler must be synchronous: use process.stderr.write, fs.writeFileSync, or SDK flushSync methods. If you need async telemetry, fire it before registering the shutdown and use .unref() on any timers so they do not block the exit.
Why does a try/catch inside a non-awaited async function not prevent the global event?
A try/catch block only catches rejections at the point of await. If you call an async function without await-ing it, the returned promise is unobserved. Any rejection inside that function — even one caught by an inner try/catch that then re-throws — propagates up to the unawaited caller and becomes unhandled. Always either await async function calls or chain .catch() on the returned promise.
Related
- Core JavaScript Error Handling & Boundaries — parent overview covering all error interception patterns
- Mastering window.onerror and Global Event Listeners — synchronous global error capture for non-promise exceptions
- Node.js uncaughtException vs unhandledRejection — when to use each Node.js process event and how they interact
- Best Practices for try/catch in Async Loops — containing per-iteration failures in
for awaitloops - How to Log Custom Error Properties Without Blooming Payloads — structured serialization strategies for rejection telemetry