Skip to content
Express.js ex errors 4 min read

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 uncaughtException as 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.

EventFires whenDefault in modern NodeYour job
uncaughtExceptionA synchronous throw escapes all try/catchCrash (exit 1)Log, flush, exit non-zero
unhandledRejectionA rejected promise has no handlerCrash (exit 1)Log, then rethrow / exit
SIGTERM / SIGINTOrchestrator or Ctrl-C asks you to stopTerminateStop 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 bare server.close() still accepts requests already mid-flight on open keep-alive sockets.

Best Practices

  • Treat uncaughtException and unhandledRejection as 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, call server.close() to drain in-flight requests, then close database pools and other resources before exiting 0.
  • 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.
Last updated June 14, 2026
Was this helpful?