Implementing Safe Shutdown with uncaughtException and unhandledRejection in Node.js
Node.js exposes two distinct global error hooks that fire at completely different points in the V8 event loop: process.on('uncaughtException') catches synchronous throws that escape every try/catch, while process.on('unhandledRejection') catches Promise rejections that were never handled before the microtask queue drains. Getting these two wrong is one of the most common causes of silent process crashes, zombie containers, and lost telemetry in production Node.js services. This guide is part of Core JavaScript Error Handling & Boundaries, and relates directly to the broader concerns covered in Handling Unhandled Promise Rejections in Modern JS and Mastering window.onerror and Global Event Listeners.
By the end of this guide you will be able to:
- Explain exactly when each hook fires relative to V8 event loop phases and microtask queue drains
- Configure
--unhandled-rejectionsCLI modes correctly for Node.js v14 through v22 - Write a bulletproof graceful shutdown handler that sets the right exit code and does not hang
- Integrate
source-map-supportwithout double-intercepting exceptions - Preserve request context across async boundaries using
AsyncLocalStorage
Problem Framing & Symptom Identification
In a long-running Node.js server, two independent failure modes can terminate the process without you ever seeing a useful stack trace in your logs.
The first is a synchronous uncaught exception: code throws inside a callback, an event handler, or module-level initialization, and no surrounding try/catch is present. Node.js fires uncaughtException synchronously, right where the throw happens, before the event loop moves to the next phase. If no handler is registered, the process prints the stack to stderr and exits with code 1.
The second is an unhandled Promise rejection: an async function rejects, but the calling code never attached a .catch() and never await-ed it inside a try/catch. V8 queues that rejection. When the microtask checkpoint runs after the current event loop phase completes, V8 checks whether a rejection handler has been attached. If not, it emits unhandledRejection.
The timing difference matters enormously. If your handler logs to a remote sink and you rely on the order of events, you may observe unhandledRejection arriving after an uncaughtException from earlier in the same tick — because the rejection had to wait for the microtask queue to drain.
Common symptoms that bring engineers to this page:
- Container exits with code
0despite a thrown exception (exit code management bug) - Duplicate log lines from both a manual handler and
source-map-support’s built-in interception unhandledRejectionfires withreasonasundefinedbecause the rejection wasthrow undefined- Process hangs after
server.close()because keep-alive connections were never destroyed - Request trace IDs missing from error logs because
AsyncLocalStoragewas not threaded through
Prerequisites & Environment Setup
This guide targets Node.js 18 LTS and above. The behavior change in v15 (switching the default --unhandled-rejections mode from warn to throw) is a prerequisite to understand before reading further.
Install the two production dependencies used in the examples:
# source-map-support maps compiled/bundled stack frames back to source
npm install source-map-support
# async_hooks is built-in to Node.js; no install needed
# For TypeScript projects also install type definitions
npm install --save-dev @types/node
Verify your Node.js version supports the origin parameter on uncaughtException (added in Node.js 12.17.0) and the --unhandled-rejections flag (added in Node.js 12.0.0):
node --version # must be >= 12.17.0; ideally >= 18.0.0
node --unhandled-rejections=throw --version # sanity-check flag is accepted
Set the flag explicitly in your process manager or container entrypoint so the behavior is deterministic regardless of Node.js version:
# package.json scripts entry
node --unhandled-rejections=throw dist/server.js
Step-by-Step Implementation
1. Register source-map support before any other require
source-map-support hooks into Error.prepareStackTrace. If you let it intercept uncaughtException automatically, it will conflict with your manual handler. Disable that behavior immediately.
require('source-map-support').install({
environment: 'node',
handleUncaughtExceptions: false, // critical: prevent double-interception
hookRequire: true, // maps require()'d compiled files inline
});
// Raise stack depth for deep async chains during development
Error.stackTraceLimit = 50;
2. Initialize AsyncLocalStorage for request context propagation
AsyncLocalStorage from node:async_hooks propagates a store object across async boundaries automatically. Register it once at module load time, then populate it per request in middleware.
const { AsyncLocalStorage } = require('node:async_hooks');
// Single shared instance for the process lifetime
const requestContext = new AsyncLocalStorage();
module.exports = { requestContext };
3. Populate context in your HTTP middleware
const { requestContext } = require('./context');
// Express-style middleware; adapt for Fastify, Koa, etc.
app.use((req, _res, next) => {
const store = {
reqId: req.headers['x-request-id'] ?? crypto.randomUUID(),
method: req.method,
path: req.path,
};
requestContext.run(store, next); // all downstream async code inherits store
});
4. Implement the isShuttingDown guard
Both hooks can fire in rapid succession — for example, a sync throw triggers uncaughtException, which starts shutdown, and a queued rejection fires unhandledRejection microseconds later. The guard prevents double-shutdown.
let isShuttingDown = false; // module-level atomic flag
function markShuttingDown() {
if (isShuttingDown) return false; // already in progress
isShuttingDown = true;
return true;
}
5. Write the uncaughtException handler
The origin parameter distinguishes a plain synchronous throw ('uncaughtException') from a Promise rejection that bubbled up through --unhandled-rejections=throw ('unhandledPromiseRejection'). Both arrive on this hook when the CLI flag converts rejections to throws.
const { requestContext } = require('./context');
process.on('uncaughtException', (err, origin) => {
// origin is 'uncaughtException' or 'unhandledPromiseRejection'
if (!markShuttingDown()) return;
const ctx = requestContext.getStore() ?? {};
// process.stderr.write is synchronous — safe inside this handler
process.stderr.write(
JSON.stringify({
level: 'fatal',
event: 'uncaughtException',
origin, // log the origin so you can triage
reqId: ctx.reqId ?? 'none',
message: err?.message,
stack: err?.stack,
ts: new Date().toISOString(),
}) + '\n'
);
gracefulShutdown(1);
});
6. Write the unhandledRejection handler
reason can be anything — not just an Error instance. A rejected Promise.reject('oops') delivers a string. Always guard with instanceof Error before accessing .stack.
process.on('unhandledRejection', (reason, _promise) => {
if (!markShuttingDown()) return;
const ctx = requestContext.getStore() ?? {};
const isError = reason instanceof Error;
process.stderr.write(
JSON.stringify({
level: 'fatal',
event: 'unhandledRejection',
reqId: ctx.reqId ?? 'none',
message: isError ? reason.message : String(reason),
stack: isError ? reason.stack : undefined,
ts: new Date().toISOString(),
}) + '\n'
);
gracefulShutdown(1);
});
7. Implement gracefulShutdown with exit code management and hard timeout
Setting process.exitCode before calling server.close() guarantees the right exit code even if the process exits via another path. The hard timeout with .unref() prevents the timer from keeping the process alive if server.close() already completed cleanly.
function gracefulShutdown(code) {
process.exitCode = code; // set before any async work
server.close(() => {
// All connections drained — exit cleanly
process.exit(code);
});
// Destroy keep-alive connections that server.close() won't touch
for (const socket of openSockets) {
socket.destroy();
}
// Hard timeout: if shutdown takes more than 5 s, force-exit
const killer = setTimeout(() => {
process.stderr.write('Graceful shutdown timed out, forcing exit\n');
process.exit(code);
}, 5000);
killer.unref(); // don't let this timer prevent natural exit
}
// Track open sockets so you can destroy keep-alive connections
const openSockets = new Set();
server.on('connection', (socket) => {
openSockets.add(socket);
socket.on('close', () => openSockets.delete(socket));
});
8. Configure the --unhandled-rejections CLI flag explicitly
Do not rely on the Node.js version default. Set the flag in every environment so behavior is predictable:
# Recommended for production (Node.js v15+ default; makes rejections fatal)
node --unhandled-rejections=throw server.js
# During migration from older codebases: warns but does not crash
node --unhandled-rejections=warn server.js
# strict: same as throw but additionally exits with a non-zero code
# even if the process would otherwise have exited cleanly
node --unhandled-rejections=strict server.js
# none: silently ignores unhandled rejections (never use this in production)
node --unhandled-rejections=none server.js
The warn-with-error-code mode emits a DeprecationWarning to stderr and exits with code 1 when the process ends naturally — useful as a transitional step when hardening a legacy codebase.
Production Telemetry Integration
Raw process.stderr.write is sufficient for log aggregation pipelines that tail stderr (Fluentd, Vector, the AWS CloudWatch agent). For structured APM integration, call the SDK’s capture method synchronously before invoking gracefulShutdown:
process.on('uncaughtException', (err, origin) => {
if (!markShuttingDown()) return;
// Sentry, Datadog, etc. — call synchronously, do NOT await
Sentry.captureException(err, { tags: { origin } });
Sentry.flush(2000).finally(() => gracefulShutdown(1));
// Sentry.flush returns a Promise but we do not await at top level;
// .finally() chains synchronously from the Promise constructor perspective
});
Be aware that Sentry.flush() itself returns a Promise. Returning or awaiting it inside the handler does nothing — Node.js ignores the return value of uncaughtException handlers. Use .then().finally() chaining and accept that there is a small window where the flush may not complete if the hard timeout fires first.
For stack trace reconstruction in production bundles, the source map generation and stack trace debugging section of this site covers how to configure Webpack, esbuild, and Rollup to emit source maps that source-map-support can load at runtime. With hookRequire: true and handleUncaughtExceptions: false (as set in step 1), every frame in err.stack inside your handlers will already be mapped back to the original TypeScript or ES module source — no additional processing needed.
For deeper context on how debugging race conditions in async error handlers works in practice, and how to implement a complete graceful shutdown on uncaughtException, see those dedicated pages.
Verification & Testing
Test the uncaughtException path by deliberately throwing outside a try/catch in a test process:
// test/crash-uncaught.js — run with: node test/crash-uncaught.js
require('../src/error-handlers'); // registers your handlers
setTimeout(() => {
throw new Error('deliberate sync throw'); // fires uncaughtException
}, 100);
Run with node test/crash-uncaught.js and assert:
- Stderr contains valid JSON with
event: 'uncaughtException'andorigin: 'uncaughtException' - The process exits with code
1(echo $?after the run)
Test the unhandledRejection path:
// test/crash-rejection.js
require('../src/error-handlers');
setTimeout(() => {
// No .catch(), no await, no try/catch — fires unhandledRejection
Promise.reject(new Error('deliberate rejection'));
}, 100);
For CI integration, use Node.js’s built-in --test runner or Jest with --forceExit:
# Assert exit code from child process
node -e "
const { spawnSync } = require('child_process');
const r = spawnSync('node', ['test/crash-uncaught.js']);
console.assert(r.status === 1, 'Expected exit code 1, got ' + r.status);
console.log('PASS: exit code is 1');
"
Test the isShuttingDown guard by firing both hooks in the same tick and confirming only one shutdown sequence runs (the log should contain exactly one gracefulShutdown invocation).
Failure Modes & Edge Cases
| Scenario | Root Cause | Fix |
|---|---|---|
Process exits with code 0 after an unhandled rejection |
--unhandled-rejections=warn (Node.js v14 default) emits a warning but does not crash |
Upgrade to --unhandled-rejections=throw or add an explicit unhandledRejection handler that calls process.exit(1) |
unhandledRejection fires with reason as undefined |
Code called Promise.reject() or reject() with no argument |
Always reject with an Error instance; add a guard in the handler: const isError = reason instanceof Error |
| Double log lines for the same exception | source-map-support handleUncaughtExceptions is still true (the default) alongside a manual handler |
Set handleUncaughtExceptions: false in the source-map-support install call |
Server hangs indefinitely after server.close() |
Keep-alive HTTP connections hold the socket open; server.close() stops accepting but does not destroy existing connections |
Track open sockets in a Set and call socket.destroy() on each in the shutdown handler |
Async handler loses AsyncLocalStorage context |
The handler itself is registered outside any AsyncLocalStorage.run() scope, so getStore() returns undefined |
Guard with ?? {} and log a reqId: 'none' sentinel rather than crashing; the store is per-request, not per-process |
uncaughtException handler fires with origin: 'unhandledPromiseRejection' unexpectedly |
--unhandled-rejections=throw converts unhandled rejections into synchronous throws, routing them through uncaughtException instead of unhandledRejection |
Log the origin parameter to distinguish; do not duplicate shutdown logic between the two handlers |
FAQ
Why does unhandledRejection fire after uncaughtException in the same test run?
uncaughtException fires synchronously the moment a throw escapes the call stack — it interrupts execution immediately. An unhandled Promise rejection, by contrast, is scheduled in the microtask queue. The queue only drains after the current synchronous execution completes and the event loop transitions between phases. So if a test triggers both a sync throw and a Promise rejection in the same tick, uncaughtException fires first, then unhandledRejection fires at the next microtask checkpoint.
What changed in Node.js v15 regarding unhandled rejections?
Before v15, the default --unhandled-rejections mode was warn: Node.js printed a UnhandledPromiseRejectionWarning to stderr but kept running. Starting in v15, the default changed to throw, which converts the rejection into an uncaught exception and terminates the process. This broke many codebases that silently swallowed rejections. If you are migrating a legacy application, set --unhandled-rejections=warn-with-error-code as a transitional step so you see the warnings without crashes, then fix the rejections before switching to throw.
Why can’t I use an async function as the uncaughtException handler?
Node.js does not await event handler callbacks. An async handler returns a Promise immediately, and Node.js discards that Promise. Any await inside the handler — await logger.flush(), await telemetry.send() — will not complete before process.exit() is called by the runtime or by your own synchronous shutdown code. Use synchronous writes (process.stderr.write) for critical log lines and accept that async telemetry flushes operate on a best-effort basis with a hard timeout.
Should I ever swallow an uncaughtException and continue running? The Node.js documentation explicitly discourages this. After an uncaught exception, the application is in an undefined state — heap objects may be corrupt, file descriptors may be leaked, and invariants may be violated. The correct pattern is to log the error, flush telemetry synchronously where possible, drain active connections with a timeout, and exit. Rely on your process manager (PM2, systemd, Kubernetes restartPolicy) to restart the process into a clean state.
Related
- Core JavaScript Error Handling & Boundaries — parent overview covering all global error interception patterns
- Handling Unhandled Promise Rejections in Modern JS — deep dive on Promise rejection lifecycle,
PromiseRejectionEvent, and browser vs Node.js differences - Mastering window.onerror and Global Event Listeners — browser-side global error hooks and their relationship to the Node.js equivalents
- Debugging Race Conditions in Async Error Handlers — diagnosing interleaved handler execution and non-deterministic shutdown ordering
- Source Map Generation & Stack Trace Debugging — configuring source maps so stack frames in error handlers point to original source