Process-Level Error Handling
Error-handling middleware catches problems that surface during a request, but some failures escape that scope entirely: a bug in a timer callback, a rejected promise no one awaited, or a SIGTERM from your orchestrator during a deploy. These are process-level events, and Node’s process object is where you catch them. This page covers the uncaughtException and unhandledRejection handlers, why the correct response is to log and exit rather than soldier on, and how to drain in-flight requests with a graceful shutdown on SIGTERM.
Why a leaked error is different from a request error
When your error-handling middleware runs, the framework’s state is intact — you know which request failed, and you can return a clean 500. An uncaught exception is the opposite: it fires because no try/catch and no middleware caught the error, so you have no idea what invariants are still valid. A connection may be half-written, a transaction half-committed, a lock never released. Node’s own guidance is blunt: after an uncaughtException, the process is in an undefined state, and the only safe move is to log diagnostics and restart.
// This escapes Express entirely — no middleware will ever see it
setTimeout(() => {
throw new Error('boom from a detached callback');
}, 100);
Output:
/app/server.js:3
throw new Error('boom from a detached callback');
^
Error: boom from a detached callback
at Timeout._onTimeout (/app/server.js:3:9)
# Without a handler, Node prints the stack and exits with code 1.
Catching uncaught exceptions
process.on('uncaughtException', ...) lets you run code before the process dies — but its job is cleanup and logging, not recovery. Do not swallow the error and keep serving traffic. Instead, record it, flush your logger, and exit with a non-zero code so your supervisor (pm2, systemd, Kubernetes) restarts a fresh instance.
process.on('uncaughtException', (err, origin) => {
// origin is 'uncaughtException' or 'unhandledRejection' on newer Node
logger.fatal({ err, origin }, 'uncaught exception — shutting down');
// Give the logger a moment to flush, then exit non-zero so we get restarted.
setTimeout(() => process.exit(1), 500).unref();
});
Warning: Never use
uncaughtExceptionas a “global try/catch” to keep the server alive. Continuing after one corrupts state and produces bugs that are impossible to reproduce. Log, exit, restart.
Catching unhandled promise rejections
A promise that rejects with no .catch() triggers unhandledRejection. In Node 15+ the default behavior is to crash the process, the same as an uncaught exception — so you should treat it identically. The cleanest pattern is to rethrow it into the uncaughtException handler so you have a single shutdown path.
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'unhandled rejection');
// Convert it into an uncaught exception so one handler owns shutdown.
throw reason instanceof Error ? reason : new Error(String(reason));
});
These handlers are a safety net, not a strategy. Every rejection they catch is a bug — an await you forgot to wrap or a promise you forgot to return. Use them to detect and surface those bugs, not to mask them.
| Event | Fires when | Default in modern Node | Your job |
|---|---|---|---|
uncaughtException | A synchronous throw escapes all try/catch | Crash (exit 1) | Log, flush, exit non-zero |
unhandledRejection | A rejected promise has no handler | Crash (exit 1) | Log, then rethrow / exit |
SIGTERM / SIGINT | Orchestrator or Ctrl-C asks you to stop | Terminate | Stop accepting, drain, exit 0 |
Graceful shutdown on SIGTERM
When you deploy or scale down, your platform sends SIGTERM and expects the process to exit. If you exit immediately, in-flight requests are cut off mid-response. Graceful shutdown means: stop accepting new connections, let active requests finish, close resources, then exit. server.close() does exactly the first two — it stops listening and invokes its callback once all open connections drain.
const express = require('express');
const app = express();
app.get('/work', async (req, res) => {
await new Promise((r) => setTimeout(r, 2000)); // simulate slow work
res.json({ ok: true });
});
const server = app.listen(3000, () => console.log('listening on :3000'));
function shutdown(signal) {
console.log(`${signal} received — closing server`);
server.close((err) => {
if (err) {
console.error('error during close', err);
process.exit(1);
}
console.log('all connections drained — exiting');
process.exit(0);
});
// Safety timeout: if connections won't drain, force exit.
setTimeout(() => {
console.error('forced shutdown after timeout');
process.exit(1);
}, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Output:
listening on :3000
SIGTERM received — closing server
all connections drained — exiting
The .unref() on the safety timer is important: it prevents that timeout from keeping the event loop alive if everything else has already finished. If you also hold a database pool, close it inside the server.close() callback — for example await pool.end() — before calling process.exit(0).
Tip: Behind a load balancer, briefly fail your health check before calling
server.close()so traffic is routed away while existing requests drain. A bareserver.close()still accepts requests already mid-flight on open keep-alive sockets.
Best Practices
- Treat
uncaughtExceptionandunhandledRejectionas fatal: log full diagnostics, then exit with a non-zero code and let a supervisor restart you. - Never keep serving traffic after an uncaught error — the process state is undefined and unsafe.
- Always run a process manager (pm2, systemd, Kubernetes) so a crashed instance is replaced automatically.
- On
SIGTERM, callserver.close()to drain in-flight requests, then close database pools and other resources before exiting0. - Add a forced-exit safety timeout (with
.unref()) so a stuck connection can’t block shutdown forever. - Fix the root cause of every rejection these handlers catch — they are a tripwire for bugs, not a recovery mechanism.