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-rejections CLI 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-support without double-intercepting exceptions
  • Preserve request context across async boundaries using AsyncLocalStorage
Node.js event loop: uncaughtException vs unhandledRejection firing points A flow diagram of the Node.js event loop phases. A synchronous throw escapes the call stack and immediately fires uncaughtException before any phase transition. An unhandled Promise rejection is queued in the microtask checkpoint and fires unhandledRejection after the current phase completes but before the next macrotask begins. timers setTimeout/setInterval pending I/O deferred callbacks poll incoming I/O events check setImmediate close callbacks socket.on('close') microtask checkpoint Promise .then() / queueMicrotask() drains here after each phase unhandledRejection fires when microtask queue drains with no rejection handler attached uncaughtException fires immediately on sync throw throw escapes call stack call stack sync execution after each phase gracefulShutdown() process.exitCode = 1 → server.close() → hard timeout with .unref()

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 0 despite a thrown exception (exit code management bug)
  • Duplicate log lines from both a manual handler and source-map-support’s built-in interception
  • unhandledRejection fires with reason as undefined because the rejection was throw undefined
  • Process hangs after server.close() because keep-alive connections were never destroyed
  • Request trace IDs missing from error logs because AsyncLocalStorage was 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:

  1. Stderr contains valid JSON with event: 'uncaughtException' and origin: 'uncaughtException'
  2. 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.