> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# UnhandledPromiseRejection Crashes Your Node Process Now
- URL: https://debugly.dev/unhandled-promise-rejection-node-crash/
- Published: 2025-08-12T09:00:00.000Z
- Updated: 2026-10-23T09:13:14.000Z
- Description: It used to be a warning. Since Node 15 it terminates the process, and that change surfaced a lot of latent bugs.
- Author: Rohit Bhadani
- Tags: Node.js, JavaScript, Error Autopsy

## 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:

```bash
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

```javascript
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:

```javascript
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

```javascript
// 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.

```javascript
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

```javascript
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.

```javascript
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:

```javascript
// 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:

```javascript
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

```javascript
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:

```javascript
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:

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

```

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

```javascript
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:

```javascript
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](https://debugly.dev/crashloopbackoff-kubernetes-diagnostic-guide/).

**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.

```json
{
  "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](https://debugly.dev/ai-code-passes-tests-breaks-production/): the untested branch is the one with the bug.

---

Related reading: [Module Not Found in Vite but Fine in Webpack](https://debugly.dev/module-not-found-vite-but-not-webpack/) explores a connected angle.

Related reading: [Maximum Call Stack Size Exceeded When It Is Not Recursion](https://debugly.dev/maximum-call-stack-size-exceeded/) explores a connected angle.

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)