Uncaught Exceptions and Graceful Shutdown

Every long-running Node.js program eventually faces two hard questions: what happens when an error slips past every try/catch, and what happens when the operating system tells the process to stop? Get the first one wrong and your server can keep running in a corrupted, unpredictable state while still accepting traffic. Get the second one wrong and you drop in-flight requests, leave database connections open, or get forcibly killed mid-write by your process manager or container orchestrator. This lesson covers process-level error handling and graceful shutdown so your Node.js applications fail safely and stop cleanly.

Overview: How Node Handles Fatal Errors and Shutdown

In Node.js, the global process object is an EventEmitter. Two of its events matter most for reliability: uncaughtException, which fires when a thrown error is never caught by any try/catch or error-first callback, and unhandledRejection, which fires when a rejected Promise has no .catch() and no await wrapped in a try/catch. Without a listener, an uncaught exception prints a stack trace to stderr and terminates the process with exit code 1 — that default behavior is actually a safety feature, not a bug to work around.

Historically, an unhandled rejection only logged a warning and let the process keep running. That changed in Node 15: by default, an unhandled rejection now behaves like an uncaught exception and terminates the process. This is important because a rejected promise almost always signals the same kind of programming error as a thrown exception (a failed database call, a bad JSON parse, a network timeout) — Node no longer lets those slide silently.

The Node.js documentation is explicit that uncaughtException is a coarse, last-resort mechanism, not a routine error-handling tool. Once an exception has escaped all the way to the top, the process may have leaked file descriptors, left locks held, or corrupted in-memory state. The only safe response is to log the error and exit — not to keep serving requests.

Shutdown works through OS signals. When you press Ctrl+C in a terminal, the OS sends SIGINT. When Docker, Kubernetes, or systemd ask a process to stop, they send SIGTERM. Node’s libuv layer turns these into ordinary events on process, so you can listen for them just like any other event and run cleanup code — close the HTTP server, close database pools, flush logs — before actually exiting. SIGKILL is the exception: it cannot be caught, handled, or ignored, so it should only ever be your orchestrator’s last resort after a grace period.

Finally, understand the difference between process.exit(code) and process.exitCode = code. Calling process.exit() terminates immediately, abandoning any pending I/O (a half-written log line, a socket write still in the kernel buffer). Setting process.exitCode instead just records the exit code the process will use once the event loop naturally drains and there is nothing left to do — this is the safer choice whenever you can let the loop empty on its own.

Syntax

process.on("uncaughtException", (err, origin) => { /* ... */ });
process.on("unhandledRejection", (reason, promise) => { /* ... */ });
process.on("SIGTERM", () => { /* ... */ });
process.on("SIGINT", () => { /* ... */ });
process.exitCode = 1;
process.exit(1);
server.close((err) => { /* ... */ });
  • uncaughtException — handler receives the Error object and an origin string ("uncaughtException" or "unhandledRejection"); use it only to log and exit.
  • unhandledRejection — handler receives the rejection reason (usually an Error) and the Promise that rejected.
  • SIGTERM / SIGINT — signal names you can listen for like any event; run your cleanup logic here.
  • process.exitCode — sets the code the process will exit with once the event loop drains naturally; does not force an immediate exit.
  • process.exit(code) — terminates synchronously right away; pending I/O may be lost.
  • server.close(callback) — stops the HTTP server from accepting new connections and calls back once all existing connections have finished.

Examples

Example 1: Catching an uncaught exception

import http from "node:http";

process.on("uncaughtException", (err) => {
  console.error("Uncaught exception:", err);
  process.exit(1);
});

const server = http.createServer((req, res) => {
  if (req.url === "/crash") {
    throw new Error("Something broke in a request handler");
  }
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("OK");
});

server.listen(3000, () => {
  console.log("Server listening on port 3000");
});
Server listening on port 3000
# after a request to /crash:
Uncaught exception: Error: Something broke in a request handler
    at Server.<anonymous> (/app.js:9:11)
    ...
# process then exits with code 1

The server starts normally, but a request to /crash throws inside the request handler with nothing to catch it. Because a listener is attached, Node calls it instead of just printing the trace and exiting on its own — but the handler still logs and exits, since continuing would leave the server in an unknown state.

Example 2: Handling unhandled promise rejections

process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled rejection:", reason);
  process.exitCode = 1;
});

async function loadConfig() {
  throw new Error("config file missing");
}

loadConfig();

console.log("synchronous code keeps running");
synchronous code keeps running
Unhandled rejection: Error: config file missing
    at loadConfig (/app.js:6:9)
    ...
# exit code is 1 once the event loop has nothing left to do

loadConfig() is called without await or .catch(), so its rejection has nowhere to go. Notice that "synchronous code keeps running" prints first — the rejection is only reported as a microtask once the current synchronous code finishes, which is exactly the kind of ordering mistake that causes this bug in real code (a forgotten await on a background task).

Example 3: A production-style graceful shutdown

import http from "node:http";

const server = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello from the server\n");
});

server.listen(3000, () => {
  console.log("Server listening on port 3000");
});

let shuttingDown = false;

function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`\nReceived ${signal}, shutting down gracefully...`);

  server.close((err) => {
    if (err) {
      console.error("Error while closing server:", err);
      process.exitCode = 1;
    } else {
      console.log("All connections closed, exiting.");
    }
    process.exit();
  });

  setTimeout(() => {
    console.error("Forcing shutdown after timeout");
    process.exit(1);
  }, 10_000).unref();
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

process.on("uncaughtException", (err) => {
  console.error("Uncaught exception:", err);
  process.exitCode = 1;
  shutdown("uncaughtException");
});
Server listening on port 3000
# after Ctrl+C:
Received SIGINT, shutting down gracefully...
All connections closed, exiting.

This is the pattern to actually ship. A shuttingDown flag makes the function idempotent, since both SIGINT and an uncaught exception might trigger it. server.close() stops accepting new connections but waits for in-flight requests to finish before its callback runs and the process exits. The setTimeout with .unref() is the safety net: if some connection never closes (a slow client, a stuck keep-alive socket), the process is forced to exit after 10 seconds instead of hanging forever until the orchestrator sends SIGKILL. .unref() also ensures that timer itself doesn’t keep the process alive if shutdown finishes normally first.

How It Works Step by Step

  • The OS (or a tool like Docker) sends a signal, e.g. SIGTERM, to the Node process.
  • libuv’s signal watcher picks it up and Node emits it as an ordinary event on process.
  • Your registered handler runs synchronously, on the main thread, like any other event listener.
  • server.close() immediately stops the server from accepting new connections, but does not touch requests already in progress.
  • Once every existing connection finishes and closes, the callback passed to server.close() fires.
  • Inside that callback you call process.exit() (or just let exitCode control the eventual exit), and the process terminates.
  • If cleanup never finishes (a hung connection, a stuck database client), the parallel setTimeout forces an exit so the process doesn’t hang indefinitely.

For an uncaught exception without a graceful-shutdown handler, the order is simpler: the error propagates up through the call stack, finds no catch, Node emits uncaughtException, and if nothing listens for it, Node prints the stack trace to stderr and calls process.exit(1) itself — you don’t need to do anything for that baseline safety net to work, but leaving it as the only defense means no cleanup ever runs.

Signal Typical source Catchable? Meaning
SIGINT Ctrl+C in a terminal Yes Interrupt from the keyboard
SIGTERM kill <pid>, Docker/Kubernetes stop Yes Polite request to terminate
SIGHUP Controlling terminal closed Yes Hangup detected on the controlling terminal
SIGKILL kill -9, orchestrator hard kill No Immediate termination; cannot be caught or delayed

Common Mistakes

Mistake 1: Using uncaughtException to resume, not exit

process.on("uncaughtException", (err) => {
  console.error("Ignoring error:", err.message);
});

setInterval(() => {
  console.log("still running...");
}, 1000);

This handler logs the error but never exits, so the process appears to keep working. The Node.js docs are explicit that this is unsafe: once an exception escapes uncaught, the process may be in an inconsistent state (a corrupted variable, a half-updated cache, a leaked handle), and continuing to serve requests risks silent data corruption instead of a clean, visible crash.

process.on("uncaughtException", (err) => {
  console.error("Uncaught exception, shutting down:", err);
  process.exitCode = 1;
  process.exit();
});

The fix logs the error and exits with a non-zero code. Pair this with a process manager (Docker’s restart policy, Kubernetes, pm2) that restarts the process automatically — a clean crash-and-restart is far safer than a process that limps along corrupted.

Mistake 2: Doing async cleanup in the exit event

process.on("exit", (code) => {
  console.log("Cleaning up before exit...");
  closeDatabaseConnection().then(() => {
    console.log("Cleanup finished");
  });
});

The exit event fires synchronously in the final microtask before the process actually terminates. Any asynchronous work started inside it — a promise, a timer, another I/O call — is simply abandoned; "Cleanup finished" never prints because the process is already gone by the time that promise would resolve.

async function closeDatabaseConnection() {
  // placeholder for real teardown logic
}

async function shutdown() {
  console.log("Cleaning up before exit...");
  await closeDatabaseConnection();
  console.log("Cleanup finished, exiting now");
  process.exit(0);
}

process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);

Do async teardown in a SIGTERM/SIGINT handler instead, and only call process.exit() after the await completes. The exit event should only be used for last-instant synchronous work, like flushing a log line already in memory.

Mistake 3: No forced-exit timeout

Relying only on server.close()‘s callback is risky: if even one client holds a keep-alive connection open, that callback may never fire, and your container will eventually be killed with SIGKILL instead of exiting cleanly. Always pair graceful shutdown with a forced-exit timer, as shown in Example 3, so a hung connection produces a fast, controlled exit instead of an indefinite hang.

Best Practices

  • Treat uncaughtException as a last resort for logging and exiting — never as a way to keep serving requests after an unexpected error.
  • Always listen for unhandledRejection explicitly; don’t rely on every promise chain having a .catch(), because one missed await anywhere in the codebase will otherwise crash the process with an unhandled trace.
  • Listen for both SIGTERM and SIGINT so the same shutdown path runs whether you’re testing locally with Ctrl+C or running in a container that sends SIGTERM.
  • Make your shutdown function idempotent (guard with a boolean flag) since multiple signals or an exception plus a signal can otherwise trigger it twice.
  • Always set a forced-exit timeout with .unref() alongside server.close() so a stuck connection can’t hang your process forever.
  • Prefer process.exitCode = n over process.exit(n) whenever you can let the event loop drain naturally — it avoids truncating pending writes.
  • Close every resource that holds the process open during shutdown: HTTP servers, database pools, message queue consumers, and open file handles.
  • Log the full Error object (not just err.message) on uncaught exceptions and rejections so the stack trace is available for debugging in production logs.

Practice Exercises

  • Write a small HTTP server with one route that intentionally throws inside the handler. Add an uncaughtException listener that logs the error and exits with code 1, and verify with echo $? in your shell that the exit code is actually 1.
  • Create a script with an async function that rejects without being awaited. Add an unhandledRejection handler, and experiment with removing it to observe Node’s default behavior (a printed warning/trace and a non-zero exit).
  • Extend the Example 3 graceful shutdown server to also close a fake “database pool” (an object with an async close() method that resolves after a 2-second delay) before calling process.exit(), and confirm the forced-exit timeout still fires if you make the fake close hang forever.

Summary

  • uncaughtException and unhandledRejection are your last line of defense for errors that escaped every try/catch and .catch() — use them to log and exit, never to keep running.
  • Since Node 15, an unhandled promise rejection terminates the process by default, just like an uncaught exception.
  • SIGTERM and SIGINT are catchable signals used to request a clean shutdown; SIGKILL cannot be caught.
  • A graceful shutdown stops accepting new connections with server.close(), waits for in-flight work to finish, and always includes a forced-exit timeout as a safety net.
  • Only synchronous work belongs in the exit event; async cleanup belongs in your signal handlers, awaited before calling process.exit().
  • Prefer process.exitCode over an immediate process.exit() whenever the event loop can be allowed to drain on its own.