javascript 112 lines · 3 tabs

Graceful Node.js Shutdown on uncaughtException and unhandledRejection

Shared by codesnips Sep 2026
3 tabs
const SHUTDOWN_TIMEOUT_MS = 10_000;

function registerFatalHandlers({ logger, onFatal, exitCode = 1 }) {
  let shuttingDown = false;

  async function handleFatal(kind, error) {
    if (shuttingDown) {
      logger.error({ kind }, 'fatal error during shutdown, forcing exit');
      process.exit(exitCode);
    }
    shuttingDown = true;

    logger.fatal({ kind, err: error }, 'fatal error, shutting down');

    const watchdog = setTimeout(() => {
      logger.error('shutdown timed out, forcing exit');
      process.exit(exitCode);
    }, SHUTDOWN_TIMEOUT_MS);
    watchdog.unref();

    try {
      await onFatal(error);
      process.exit(exitCode);
    } catch (cleanupErr) {
      logger.error({ err: cleanupErr }, 'error during graceful shutdown');
      process.exit(exitCode);
    }
  }

  process.on('uncaughtException', (err) => {
    handleFatal('uncaughtException', err);
  });

  process.on('unhandledRejection', (reason) => {
    const err = reason instanceof Error ? reason : new Error(String(reason));
    handleFatal('unhandledRejection', err);
  });
}

module.exports = { registerFatalHandlers, SHUTDOWN_TIMEOUT_MS };
3 files · javascript Explain with highlit

This snippet shows the standard pattern for handling fatal, out-of-band errors in a Node.js process: uncaughtException and unhandledRejection. The key insight is that once an exception escapes to uncaughtException, the process is in an undefined state — some part of the event loop threw and nobody caught it, so continuing to serve traffic risks corrupted state or leaked resources. The correct move is to log the error with full context and then exit, letting a supervisor (systemd, Kubernetes, PM2) restart a clean process.

The fatal handlers tab installs both listeners exactly once via registerFatalHandlers. It guards against re-entrancy with a shuttingDown flag so a second error during cleanup can't trigger a second overlapping shutdown. On unhandledRejection it wraps non-Error reasons so downstream logging always sees a real stack. It calls a caller-supplied onFatal (typically the server's shutdown) with a hard SHUTDOWN_TIMEOUT_MS watchdog: if graceful cleanup hangs, setTimeout(...).unref() fires and forces process.exit(1). The .unref() matters — it stops the timer itself from keeping the event loop alive.

The http server tab wires this into a real service. createServer returns a shutdown function that stops accepting new connections via server.close(), then waits for in-flight requests to drain before resolving. trackConnections keeps a Set of open sockets so a slow client can be destroyed after the drain window. The same shutdown is reused for SIGTERM and SIGINT, giving one code path for both operator-initiated and crash-initiated exits — an important consistency, since duplicating shutdown logic is a common source of resource leaks.

The bootstrap tab is the composition root: it builds the app, starts the server, and passes server.shutdown into registerFatalHandlers with the exit code. The distinction between unhandledRejection and uncaughtException deserves care: promise rejections are often recoverable application bugs, and some teams choose to log-and-continue there. This implementation treats both as fatal for safety, favoring a fast restart over a zombie process. A subtle pitfall avoided here is doing async work inside the handler without a timeout — without the watchdog, a stuck flush would hang the process forever and defeat the restart. Reach for this pattern in any long-running Node service where correctness after an unexpected throw cannot be guaranteed.


Related snips

Share this code

Here's the card — post it anywhere.

Graceful Node.js Shutdown on uncaughtException and unhandledRejection — share card
Link copied