Skip to content
Node.js nd error-handling 5 min read

uncaughtException & unhandledRejection

No matter how carefully you wrap your code in try/catch, some errors will slip through — a throw in a timer callback, a rejected Promise nobody awaited, a bug in a third-party library. Node.js gives you two last-resort hooks on the process object, uncaughtException and unhandledRejection, to observe these failures before the process dies. They are a safety net for logging and graceful shutdown, not a license to keep a broken process running. This page explains how they fire, what Node does by default, and the only sane way to use them.

Node’s default behavior

When an exception propagates all the way to the top of the stack with no try/catch to catch it, Node prints the stack trace to stderr and exits with a non-zero code (1).

// crash.js
setTimeout(() => {
  throw new Error('Boom from a timer');
}, 10);

Output:

$ node crash.js
/app/crash.js:3
  throw new Error('Boom from a timer');
  ^

Error: Boom from a timer
    at Timeout._onTimeout (/app/crash.js:3:9)

Node.js v22.11.0

Since Node.js 15, an unhandled Promise rejection behaves the same way: the default unhandledRejection mode is throw, which crashes the process with a non-zero exit code. Earlier versions only logged a deprecation warning, so code that “worked” on Node 14 may now terminate on Node 20/22.

// reject.js — no .catch(), nothing awaits this
Promise.reject(new Error('Nobody handled me'));

Output:

$ node reject.js
node:internal/process/promises:391
    triggerUncaughtException(err, true /* fromPromise */);
    ^

Error: Nobody handled me
    at Object.<anonymous> (/app/reject.js:2:16)

Node.js v22.11.0

This crash-by-default is intentional and good. A process that hit an unexpected error is in an unknown state. Letting it die — under a supervisor like systemd, PM2, or Kubernetes that restarts it — is safer than limping along with corrupted memory.

Catching uncaught exceptions

process.on('uncaughtException', handler) fires for any synchronous error that reaches the top of the stack unhandled. Registering a listener suppresses Node’s default crash, which is exactly the trap to avoid: once you handle the event, you are responsible for exiting.

import process from 'node:process';

process.on('uncaughtException', (err, origin) => {
  // origin is 'uncaughtException' or 'unhandledRejection'
  console.error(`FATAL [${origin}]:`, err);
  // Flush logs, then exit. Do NOT resume normal operation.
  process.exit(1);
});

setTimeout(() => {
  throw new Error('Boom from a timer');
}, 10);

Output:

FATAL [uncaughtException]: Error: Boom from a timer
    at Timeout._onTimeout (/app/handler.js:11:9)

The handler receives the error plus an origin string telling you whether it came from a throw or a rejection. After this point the event loop is in an undefined state — V8 cannot guarantee that any further code runs correctly — so the only safe action is to log synchronously and exit.

Catching unhandled rejections

unhandledRejection fires when a Promise rejects and no rejection handler is attached by the end of the current microtask turn. The handler receives the rejection reason (usually an Error) and the promise itself.

import process from 'node:process';

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled rejection at:', promise, 'reason:', reason);
  // Convert to an uncaught exception so a single path handles shutdown.
  throw reason;
});

async function loadUser() {
  const res = await fetch('https://api.example.com/user/42');
  return res.json(); // if fetch rejects and the caller forgets to await...
}

loadUser(); // not awaited — a rejection here goes unhandled

A common pattern is to re-throw the reason inside unhandledRejection so it becomes an uncaughtException, funnelling both failure types through one logging-and-exit routine.

Why you should log and exit, not continue

The single most important rule: never use these handlers to swallow errors and keep going. They exist to give you a clean final breath — flush a log line, alert your monitoring, close the HTTP server so in-flight requests finish — and then terminate.

If you…Result
Log and process.exit(1)Correct. Supervisor restarts a fresh, healthy process.
Log and return (resume)Dangerous. State may be corrupt; leaks and zombie behavior follow.
Register no handler at allNode crashes with a stack trace and exit code 1 — also acceptable.

A slightly more graceful exit drains active connections first, with a hard timeout so a stuck shutdown can’t hang forever:

import process from 'node:process';
import http from 'node:http';

const server = http.createServer((req, res) => res.end('ok'));
server.listen(3000);

process.on('uncaughtException', (err) => {
  console.error('Uncaught exception, shutting down:', err);
  server.close(() => process.exit(1)); // stop accepting, finish in-flight
  setTimeout(() => process.exit(1), 5000).unref(); // hard cap
});

Pair the global handlers with a process manager. Without an automatic restart, “log and exit” just means your service goes down. The handler reports the crash; the supervisor brings it back.

uncaughtExceptionMonitor

If you only want to observe errors for logging — without disabling Node’s default crash — use the uncaughtExceptionMonitor event. It fires before Node terminates but does not prevent the exit, so you get the best of both worlds: your telemetry runs, and the process still dies the way Node intended.

import process from 'node:process';

process.on('uncaughtExceptionMonitor', (err, origin) => {
  // Report to your observability platform; Node still crashes after this.
  reportToSentry(err, origin);
});

function reportToSentry(err, origin) {
  console.error(`[monitor:${origin}]`, err.message);
}

Best practices

  • Treat uncaughtException and unhandledRejection as fatal — log synchronously, then call process.exit(1); never resume normal work.
  • Run under a supervisor (systemd, PM2, Kubernetes) so the killed process is restarted automatically.
  • Prefer uncaughtExceptionMonitor when you only need to log, since it keeps Node’s safe default crash behavior intact.
  • Re-throw inside unhandledRejection to route both failure types through one shutdown path.
  • On Node 15+, assume unhandled rejections crash the process by default — fix the missing .catch()/await, don’t silence it.
  • Keep handler code minimal and dependency-free; the event loop is in an unknown state, so avoid async work beyond a short, timed-out graceful shutdown.
  • Use these globals as a backstop only — handle errors locally with try/catch and .catch() wherever you reasonably can.
Last updated June 14, 2026
Was this helpful?