Unhandled Rejections

Unhandled Rejections and Uncaught Exceptions

Here is a server that works until the upstream service moves. The handler calls an async function with .then() and never attaches a .catch():

crash.mjs — one missing .catch() takes the whole server downJavaScript
import { createServer } from 'node:http';
async function loadUser(id) {
  const res = await fetch(`https://users.example.com/${id}`);
  return res.json();
}
createServer((req, res) => {
  loadUser(7).then((user) => res.end(JSON.stringify(user)));
}).listen(3000, () => console.log('listening on 3000'));
Output
listening on 3000
node:internal/process/promises:332
    triggerUncaughtException(err, true /* fromPromise */);
[TypeError: fetch failed] {
  [cause]: Error: getaddrinfo ENOTFOUND users.example.com {
    errno: -3008, code: 'ENOTFOUND', syscall: 'getaddrinfo'
  }
}

The process is gone, exit code 1 — one bad request killed every other connection the server was holding, and fromPromise in the internal frame names the culprit. 1 traces the route it took.

How an unhandled rejection becomes a dead process
How an unhandled rejection becomes a dead process

An unhandled rejection is promoted to an uncaught exception by default, and that is right: a rejection nobody handles is a bug, a crashed process gets restarted by your supervisor, and a limping one serves garbage for hours. The fix is not a global hook:

fixed.mjs — handle at the request, keep the hooks as a last resortJavaScript
const server = createServer(async (req, res) => {
  try {
    res.end(JSON.stringify(await loadUser(7)));
  } catch (err) {
    console.error('request failed:', err.message, '|', err.cause?.code);
    res.statusCode = 502;
    res.end('{"error":"upstream unavailable"}');
  }
});
function shutdown(code) {
  server.close(() => process.exit(code));
  setTimeout(() => process.exit(code), 5000).unref();
}
process.on('unhandledRejection', (r) => { console.error('LAST RESORT:', r); shutdown(1); });
process.on('uncaughtException', (e, o) => { console.error(o + ':', e.message); shutdown(1); });
Output
request failed: fetch failed | ENOTFOUND
request failed: fetch failed | ENOTFOUND

Making the handler async and wrapping the await in try/catch is the whole repair: the client gets a 502 and the server stays up, request after request. The two hooks rescue nothing — they log what the surrounding code failed to handle and then still shut down, refusing new connections while in-flight responses finish, with a 5 s unref'd timer as a backstop. Resuming is unsafe by design: a throw can leave a half-written file or an unreleased lock.

Unhandled rejection modes, measured on Node 25.8.0
--unhandled-rejections= With no handler installed Exit
throw (default), strict Raised as an uncaught exception 1
warn Warning printed, process continues 0
warn-with-error-code Same warning, process continues 1
none Silent 0

Use warn only to triage a legacy codebase, never in production: it hides the bugs you need to see. process.on('uncaughtExceptionMonitor') is the one always-safe hook — it fires before the default crash, so you can log or flush metrics without changing behavior.