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
Errorobject and anoriginstring ("uncaughtException"or"unhandledRejection"); use it only to log and exit. - unhandledRejection — handler receives the rejection
reason(usually anError) and thePromisethat 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 letexitCodecontrol the eventual exit), and the process terminates. - If cleanup never finishes (a hung connection, a stuck database client), the parallel
setTimeoutforces 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
uncaughtExceptionas a last resort for logging and exiting — never as a way to keep serving requests after an unexpected error. - Always listen for
unhandledRejectionexplicitly; don’t rely on every promise chain having a.catch(), because one missedawaitanywhere in the codebase will otherwise crash the process with an unhandled trace. - Listen for both
SIGTERMandSIGINTso the same shutdown path runs whether you’re testing locally withCtrl+Cor running in a container that sendsSIGTERM. - 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()alongsideserver.close()so a stuck connection can’t hang your process forever. - Prefer
process.exitCode = noverprocess.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
Errorobject (not justerr.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
uncaughtExceptionlistener that logs the error and exits with code 1, and verify withecho $?in your shell that the exit code is actually 1. - Create a script with an
asyncfunction that rejects without being awaited. Add anunhandledRejectionhandler, 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 callingprocess.exit(), and confirm the forced-exit timeout still fires if you make the fake close hang forever.
Summary
uncaughtExceptionandunhandledRejectionare your last line of defense for errors that escaped everytry/catchand.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.
SIGTERMandSIGINTare catchable signals used to request a clean shutdown;SIGKILLcannot 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
exitevent; async cleanup belongs in your signal handlers, awaited before callingprocess.exit(). - Prefer
process.exitCodeover an immediateprocess.exit()whenever the event loop can be allowed to drain on its own.
