Breaking
Fix UnhandledPromiseRejection in Node.js

Fix UnhandledPromiseRejection in Node.js

How this was written

Drafted in plain Markdown by Ethan Laurent and edited against current Node.js, framework and tooling docs. Every command, code block and benchmark in this article was run on Node 24 LTS before publish; if a step does not work on your machine the post is wrong, not you — email and I will fix it.

AI is used as a research and outline assistant only — never as a single-source author. Full editorial policy: About / How nodewire is written.

It’s 2 a.m., the pager goes off, and your Node 22 service is in a restart loop. The logs show one line — a rejection reason — then the process exits and your orchestrator boots it again, with no request handler in the stack trace and no obvious file of yours. An unhandled promise rejection took the whole process down, and on modern Node that’s not a warning you can ignore: since Node 15 the default is throw, so a rejected promise with no .catch() crashes with exit code 1. The fix is rarely clever — nine times out of ten it’s one missing await.

The short fix

An unhandled promise rejection usually comes from a missing await, a missing return, or a promise created inside an async function without a .catch(). Fix the local async flow first. A global unhandledRejection listener is useful for logging and shutdown, but it should not be used to hide the bug and keep the process running as if nothing happened.

Here’s what you see. The legacy warning (Node 14 and older, or --unhandled-rejections=warn):

text
(node:18472) UnhandledPromiseRejectionWarning: Error: ETIMEDOUT
(node:18472) UnhandledPromiseRejectionWarning: Unhandled promise rejection.
(node:18472) [DEP0018] DeprecationWarning: Unhandled promise rejections are
deprecated. In the future, promise rejections that are not handled will
terminate the Node.js process with a non-zero exit code.

And the modern one — Node 15 through 24, the default — which doesn’t warn, it kills the process:

text
node:internal/process/promises:391
    triggerUncaughtException(err, true /* fromPromise */);
Error: ETIMEDOUT
    at Timeout._onTimeout (/app/src/notify.ts:14:18)
Node.js v22.16.0
# exit code: 1

That “In the future” line arrived for real in October 2020 with Node 15 — code that limped along on Node 14 started crashing the moment it moved to a current LTS.

Why a rejected promise crashes the whole process now

When a promise rejects and nobody is listening — no .catch(), no try/catch around an await, no second argument to .then() — the event loop flags it at the end of the turn as unhandled. Before Node 15, Node just printed UnhandledPromiseRejectionWarning and kept running, a trap that let people ship while a DB write or webhook had silently failed. Node 15 flipped the default to throw: an uncaught synchronous error crashes the program, so an unhandled rejection should crash it too. The full state machine — throw (default), strict, warn, none — lives in the Node.js process events docs. Leave it on throw: a service that crashes loudly on a dropped promise is one you’ll actually fix.

The #1 cause: a forgotten await firing into the void

The bug behind most of these tickets — you call an async function and forget to await it:

TypeScript
async function handleSignup(user: User): Promise<void> {
  await db.insert(user);
  sendWelcomeEmail(user.email); // ⚠️ no await — fire and forget, can reject unhandled
  // await sendWelcomeEmail(user.email);  ✅ the fix: rejection now lands here, in scope
}

sendWelcomeEmail returns a promise you never await or .catch(), and handleSignup returns before it settles. If the email service times out, that promise rejects with nothing attached and on Node 15+ your process dies — after handleSignup returned, so the stack trace points into the email library or a timer callback, not the line that forgot the await.

The fix is insultingly small — add the await, and the rejection propagates out to your caller’s try/catch or Express error handler. Not wanting to block signup on the email is legitimate, but “don’t block” is not “don’t handle.”

Finding the culprit when the stack points nowhere

This error eats hours because the rejection often surfaces with a stack trace that names a library, a node:internal frame, or a setTimeout — anywhere except your code. Register a process-level unhandledRejection listener (the block at the end of this article) to log the reason, which alone often names the subsystem that dropped the ball, and run with --trace-warnings --stack-trace-limit=30.

For TypeScript, the static fix beats the runtime hunt. The typescript-eslint no-floating-promises rule flags every promise-valued statement you neither awaited, returned, .catch()-ed, nor voided — at lint time, before it ships. It would have underlined that bare sendWelcomeEmail(...) call. (Node flags a rejection as “unhandled” at the end of the event-loop turn, so a .catch() attached a tick later is too late — see Node event loop explained.)

The tempting shortcut that wastes time

You add the process-level handler, see the crash stop, and ship it:

TypeScript
process.on('unhandledRejection', () => {}); // ⚠️ silence the crash, ship it

The process stays up, your pager goes quiet, and you’ve just made the bug permanent. An empty unhandledRejection handler doesn’t handle anything — it flips Node’s default from “crash” to “swallow,” and every dropped promise now fails silently. I inherited a service that did this: it “had no errors” for months, until a customer noticed half their exports were missing. The empty handler had been eating a rejected S3 upload; removing it surfaced four real bugs in an afternoon.

A process-level unhandledRejection handler is a safety net for logging and graceful shutdown, not a place to make errors disappear. Handle the rejection where it happens; use the global handler to log and exit, not to pretend.

Background jobs, queues, and framework handlers

Unhandled rejections do not only come from HTTP routes. They often come from background jobs, queue processors, cron tasks, and event listeners where there is no response object and no framework error middleware to catch the failure. Treat every worker callback as a boundary: await the async work, catch known operational failures, and let unknown failures crash the worker so the supervisor restarts it cleanly.

TypeScript
queue.process(async (job) => {
  try {
    await exportReport(job.data.reportId);
  } catch (err) {
    logger.error({ err, jobId: job.id }, 'report export failed');
    throw err; // let the queue mark the job failed and retry according to policy
  }
});

Framework support differs too. Express 5 forwards rejected async handlers to error middleware automatically, while Express 4 needs a wrapper. Fastify already treats an async route rejection as a route error and passes it through its error handler. That does not protect fire-and-forget promises created inside the handler; those still need their own .catch() if you intentionally do not await them.

Handling promises the right way

Three patterns cover nearly everything. First, await inside try/catch for anything whose failure you can act on — log it, then rethrow so the caller decides the response.

Second, an async wrapper for Express, so a rejected handler reaches your error middleware. Async handlers whose rejections Express 4 never sees are the most common source of these in web apps:

TypeScript
import type { RequestHandler } from 'express';

// funnel any rejection from the async handler to Express's error middleware
const wrap =
  (fn: RequestHandler): RequestHandler =>
  (req, res, next) =>
    Promise.resolve(fn(req, res, next)).catch(next);

app.get('/orders/:id', wrap(async (req, res) => {
  const order = await db.orders.find(req.params.id); // rejects → next(err)
  res.json(order);
}));

Express 5 awaits async handlers for you, but on 4.x this wrapper is mandatory — the full pattern is in Express async error handling.

Third, void-mark genuinely fire-and-forget work — but attach a catch first:

TypeScript
void sendWelcomeEmail(user.email).catch((err) => {
  logger.warn({ err }, 'welcome email failed (non-blocking)');
}); // void satisfies no-floating-promises; .catch satisfies Node

The void tells the linter you meant not to await it; the .catch() is what keeps Node happy. Voiding alone does not handle the rejection — skip the .catch() and you’re back to a crash. (See the MDN Promise.catch reference.)

Finally, keep a top-level net for the promise you missed — log, then shut down, since after an unhandled rejection the process is in an unknown state:

TypeScript
process.on('unhandledRejection', (reason) => {
  logger.fatal({ reason }, 'unhandled rejection — shutting down');
  shutdown().finally(() => process.exit(1)); // drain, then let the orchestrator restart
});

It still exits with 1 — not suppressing the crash, making it graceful: drain in-flight requests, flush your error reporter, then die so the orchestrator hands you a clean process. The full sequence is in Node graceful shutdown for Docker and Kubernetes. And reject with Error objects, not strings, or reason loses the stack.

FAQ

What does “unhandled promise rejection” mean in Node.js?

A promise rejected and no handler was ever attached — no .catch(), no try/catch around an await, no onRejected in .then(). Since Node 15 the default response is to throw an uncaught exception and exit code 1.

Why does my Node app crash with exit code 1 on a rejected promise?

Since Node 15 the default --unhandled-rejections mode is throw, so an unhandled rejection becomes an uncaught exception that crashes the process. Before Node 15 it only logged UnhandledPromiseRejectionWarning, which is why upgrading from Node 14 surfaces crashes.

What is the most common cause of an unhandled promise rejection?

A forgotten await on an async call — you call sendEmail(user) without awaiting or catching it, the function returns, and if that promise rejects there’s no handler in scope. Add the await, or a .catch() if it’s intentionally non-blocking.

How do I find which promise is unhandled when the stack trace points nowhere?

Register a process.on('unhandledRejection', ...) listener to log the reason, and run with --trace-warnings. In TypeScript, enable the typescript-eslint no-floating-promises rule to catch them statically before they run.

Should I just add an empty process.on(‘unhandledRejection’) handler?

No — that converts Node’s default “crash” into “silently swallow,” so every dropped promise fails invisibly and hides real bugs. Use the global handler only to log and trigger a graceful shutdown; handle each rejection where it occurs.

Does the void operator handle a rejected promise?

No. void somePromise() only tells linters like no-floating-promises that you didn’t await it on purpose — it attaches no handler, so the promise can still crash the process. Always pair it with a .catch().