UnhandledPromiseRejection Crashes Your Node Process Now

It used to be a warning. Since Node 15 it terminates the process, and that change surfaced a lot of latent bugs.

Share
UnhandledPromiseRejection Crashes Your Node Process Now. Abstract node.js illustration in orange and dark grey on debugly.dev

The short answer

[UnhandledPromiseRejection: This error originated either by throwing inside of an
async function without a catch block, or by rejecting a promise which was not
handled with .catch().]

A promise rejected and nothing handled it. Since Node 15 the default is --unhandled-rejections=throw, which terminates the process.

Do not suppress it:

node --unhandled-rejections=warn server.js   # hides the bug, do not do this

Find it instead. The stack trace usually points at the rejection site, and if it does not, the diagnostics channel below will.

Tested on Node 22.14.

Why the default changed

Before Node 15 an unhandled rejection printed a warning and carried on. That sounds friendlier and it produced a worse outcome: applications running in a corrupted state with a failed operation nobody noticed.

The reasoning is the same as for a synchronous uncaught exception. If an error escaped all your handling, you do not know what state the process is in. A half completed transaction, a connection left open, a mutation applied to one of two systems. Continuing is a guess, and crashing is the honest response.

The change did not create bugs. It made existing ones visible, which is why upgrading Node surfaced a wave of these in codebases that had been "working".

The patterns that cause it

Forgetting to await

async function handler(req, res) {
  saveAuditLog(req);       // returns a promise, not awaited
  res.json({ ok: true });
}

If saveAuditLog rejects, nothing catches it. The response has already been sent, so the failure is invisible until the process dies.

Either await it, or handle it explicitly if it is genuinely fire and forget:

void saveAuditLog(req).catch(err =>
  logger.error({ err }, "audit log failed")
);

void documents that the omission is deliberate. The .catch is what stops the crash. Doing one without the other is not enough.

The lint rule that catches this is no-floating-promises in typescript-eslint, and it is the single highest value rule for async code. Turn it on.

Rejections in an array of promises

// if any reject, the others' rejections are unhandled
const results = await Promise.all(ids.map(id => fetchUser(id)));

Promise.all rejects on the first failure. The other promises are still running, and if a second one rejects afterwards, its rejection has no handler.

const results = await Promise.allSettled(ids.map(id => fetchUser(id)));
const ok = results.filter(r => r.status === "fulfilled").map(r => r.value);
const failed = results.filter(r => r.status === "rejected");
if (failed.length) logger.warn({ count: failed.length }, "some fetches failed");

allSettled never rejects and gives you both outcomes, which is nearly always what you actually wanted.

Event handlers with async callbacks

emitter.on("message", async (msg) => {
  await process(msg);      // rejection has nowhere to go
});

EventEmitter does not understand promises. It calls your function, gets a promise back, and discards it. A rejection is unhandled by construction.

emitter.on("message", (msg) => {
  process(msg).catch(err => logger.error({ err, msg }, "handler failed"));
});

Same problem with setInterval, setTimeout, and any callback based API given an async function.

Express error handling before version 5

Express 4 does not catch rejections from async route handlers:

// Express 4: rejection escapes, no 500 sent, process crashes
app.get("/users", async (req, res) => {
  const users = await db.query("SELECT ...");
  res.json(users);
});

Express 5 handles this. On 4, wrap your handlers:

const asyncHandler = fn => (req, res, next) =>
  Promise.resolve(fn(req, res, next)).catch(next);

app.get("/users", asyncHandler(async (req, res) => { ... }));

A promise stored and awaited later

const p = riskyOperation();       // rejects at t=100ms
await somethingSlow();            // takes 500ms
await p;                          // handler attached at t=500ms, too late

Node reports the rejection at the point it decides nobody is going to handle it, which is the end of the current microtask queue. Attaching a handler afterwards does not retroactively help.

Attach the handler immediately, even if you await later:

const p = riskyOperation();
p.catch(() => {});                // claims it
await somethingSlow();
await p;                          // the real handling

Finding the source

The stack trace is often unhelpful, because promise rejections lose async context.

Enable async stack traces, which are on by default in modern Node and worth confirming:

node --stack-trace-limit=50 server.js

Register a handler that logs before exiting. This is what I put in every service:

process.on("unhandledRejection", (reason, promise) => {
  logger.fatal({
    err: reason instanceof Error ? reason : new Error(String(reason)),
    stack: reason?.stack,
  }, "unhandled rejection, shutting down");

  // let the log flush, then exit non-zero
  setTimeout(() => process.exit(1), 100).unref();
});

Note that this handler does not prevent the crash by design. It logs and exits. Registering a handler that swallows the rejection and continues puts you back in the pre-Node-15 situation, with the same corrupted state problem.

The distinction matters. Log and exit is correct. Log and continue is the bug.

Use the diagnostics channel for rejections that are hard to trace:

const diagnostics_channel = require("node:diagnostics_channel");
diagnostics_channel.subscribe("unhandledRejection", ({ reason }) => {
  console.error("rejection origin:", reason);
});

In containers

A crash on unhandled rejection is correct behaviour in a container, because the orchestrator restarts you. That is the intended interaction.

Two things to get right so it works:

Exit non-zero. process.exit(1), not process.exit(0). Exit code 0 means success and Kubernetes will treat a completed process differently from a failed one.

Flush your logs before exiting. Most logging libraries buffer. Exiting immediately loses the log line explaining why you exited, which is the worst possible outcome. The setTimeout with unref in the snippet above gives the transport a moment.

If your pod is restarting repeatedly with exit code 1 and no useful log, unflushed logs on exit is a strong suspect.

Prevention

no-floating-promises. The lint rule. It requires type information, so it needs typescript-eslint with a project config, and it is worth the setup.

{
  "rules": {
    "@typescript-eslint/no-floating-promises": "error",
    "@typescript-eslint/no-misused-promises": "error"
  }
}

The second rule catches passing an async function where a void callback is expected, which is the event handler case above.

Prefer allSettled over all unless you genuinely want to abort on first failure.

Never make a callback async unless the API explicitly supports promises.

Test the failure paths. Most unhandled rejections live in error handling code that never runs in tests, because the tests only exercise the happy path. This is the same gap that lets generated code pass tests and break in production: the untested branch is the one with the bug.