Debugging Intermittent Unhandled Promise Rejections in Node.js: A Walkthrough

Key takeaways

A representative debugging walkthrough: an Express API that crashes only under certain timing. The culprits are promises created before they are awaited, try/catch around an un-awaited return, and async handlers in Express 4. Each is reproduced and fixed with runnable code.

Introduction

UnhandledPromiseRejection is one of the most common Node.js failures, and it is at its most frustrating when it is intermittent: the endpoint works in development, passes tests, and then the process dies a few times a day in production with a stack trace that points at a database helper rather than at the code that forgot to handle the error.

This post is a representative walkthrough, not a report of one specific incident. It stitches together the patterns that most often produce this symptom, reproduces each one with a small runnable script, and shows the fix. Everything was run on Node.js 24; Express behavior is shown for both 4.x and 5.x because they differ on exactly this point.


The symptom

The log looks like this. On Node 15 and later, an unhandled rejection is no longer a warning; it is raised as an uncaught exception and the process exits with code 1:

/app/src/db.js:45
    throw new Error('Database connection failed');
    ^

Error: Database connection failed
    at query (/app/src/db.js:45:11)

Node.js v24.13.1

(If you are reading older posts or logs that show UnhandledPromiseRejectionWarning and the process keeps running, that is Node 14 and earlier. Those versions only warned by default.)

If the rejection reason is not an Error object, for example Promise.reject('timeout'), you get Node’s generic wrapper instead, which has no useful stack at all:

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(). The promise
rejected with the reason "timeout".
    at throwUnhandledRejectionsMode (node:internal/process/promises:392:7)
    ...
{
  code: 'ERR_UNHANDLED_REJECTION'
}

That is the first lesson of the walkthrough: always reject with Error objects. A string rejection throws away the one piece of evidence you need.

What makes the situation hard is the combination of traits:

  • It does not reproduce locally, where every dependency is fast and healthy.
  • It happens only a few times a day, so adding logs and waiting is slow.
  • The stack shows where the error was created, not which caller failed to handle it.

Suspect #1: a .then() without .catch()

The simplest version is a route that consumes a promise and never handles rejection:

app.get('/users/:id', (req, res) => {
  getUserData(req.params.id)
    .then(user => res.json(user));
  // no .catch(): any rejection from getUserData is unhandled
});

This one is usually found quickly with a search for .then( without a matching .catch(. It is worth reading getUserData while you are there, though, because the “not found” branch in code like this is often wrong too:

async function getUserData(id) {
  const [rows] = await pool.query('SELECT * FROM users WHERE id = ?', [id]);
  if (rows.length === 0) {           // not: if (!rows), an empty array is truthy
    throw new NotFoundError(`User ${id} not found`);
  }
  return rows[0];
}

With mysql2, pool.query() resolves to [rows, fields], so a check like if (!user) on the raw result never fires and the handler happily returns an empty result instead of a 404.


Suspect #2: the promise created too early

This is the pattern that most often explains the “only sometimes” part. The handler has a try/catch and looks careful:

async function handler() {
  const profileP = fetchProfile();   // starts now
  const ordersP = fetchOrders();     // starts now

  try {
    const profile = await profileP;
    const orders = await ordersP;
    return { profile, orders };
  } catch (e) {
    console.log('caught:', e.message);
    return null;
  }
}

The intent was to run both requests in parallel. Now make the orders service fail quickly while the profile request is still in flight:

const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function fetchProfile() { await sleep(100); return { name: 'Ada' }; }
async function fetchOrders()  { await sleep(10); throw new Error('orders service 503'); }

handler().then(r => console.log('result', r));
Error: orders service 503
    at fetchOrders (t2.js:3:55)

Node.js v24.13.1

The process crashes, and caught: is never printed. ordersP rejected at 10 ms, but nothing was listening to it yet: the function was paused at await profileP. Node checks for unhandled rejections after the microtask queue drains, sees a rejected promise with no handler, and raises it. The await ordersP that would have handled it is 90 ms in the future.

The timing dependency is the whole story. If the profile call is faster than the failing call, the code works. It only crashes when the second promise fails before the first settles, which in production means “when that downstream service is returning fast errors”, often during a partial outage, exactly when you least want the API to restart.

The fix is to hand both promises to a combinator immediately, which attaches handlers to all of them at once:

async function handler() {
  try {
    const [profile, orders] = await Promise.all([fetchProfile(), fetchOrders()]);
    return { profile, orders };
  } catch (e) {
    console.log('caught:', e.message); // now reached
    return null;
  }
}

If partial results are acceptable, use Promise.allSettled, which never rejects and gives you { status: 'fulfilled' | 'rejected', ... } for each input.

I have found this bug more than once in code written by people who understood promises well. It usually gets introduced during a performance pass, when someone notices two sequential awaits and “parallelizes” them by hoisting the calls above the try block. The review diff looks like a harmless reordering, the tests still pass because the test doubles resolve instantly, and the crash only arrives with the first slow dependency.


Suspect #3: return without await inside try

The second classic escape:

async function withoutAwait() {
  try {
    return fails();            // returns the promise; try block exits immediately
  } catch (e) {
    return 'fallback';         // never runs for async failures
  }
}

async function withAwait() {
  try {
    return await fails();      // waits inside the try, so rejections are caught
  } catch (e) {
    return 'fallback';
  }
}
withoutAwait rejected: db down
withAwait: fallback

return fails() hands the promise to the caller and leaves the try block before the promise settles, so the catch clause is dead code for asynchronous errors. Outside a try block, return await and return behave the same; inside one, they are different programs. The ESLint rule @typescript-eslint/return-await (with its default in-try-catch option) flags exactly this case.


Suspect #4: async callbacks nobody awaits

Array.prototype.forEach ignores the return value of its callback, so an async callback’s promise is dropped:

async function run() {
  [3, 1, 2].forEach(async (id) => { await save(id); });
  console.log('forEach "done"');          // prints before any save finishes

  await Promise.all([3, 1, 2].map(save)); // actually waits
  console.log('Promise.all done');
}
forEach "done"
saved 1
saved 1
saved 2
saved 2
saved 3
saved 3
Promise.all done

The first loop’s saves finish after run() has moved on, and if one of them rejects, nobody is listening. The same applies to async functions passed as event emitter listeners, setTimeout callbacks, and fire-and-forget calls like sendAnalytics(event) without await or .catch. Use for…of with await for sequential work, Promise.all(array.map(fn)) for parallel work, and an explicit .catch() on anything you intentionally do not await.


Getting a better stack trace

Old advice says to run Node with --async-stack-traces. That flag is unnecessary: V8’s zero-cost async stack traces have been enabled by default since Node 12. What matters is how the code is written, because the feature only reconstructs callers across await:

// async/await chain
async function query()    { await null; throw new Error('Database connection failed'); }
async function loadUser() { return await query(); }
async function handler()  { await loadUser(); }

// equivalent .then() chain
function query2()    { return Promise.resolve().then(() => { throw new Error('Database connection failed'); }); }
function loadUser2() { return query2().then(r => r); }
function handler2()  { return loadUser2().then(r => r); }
await chain:
Error: Database connection failed
    at query (a6.js:1:44)
    at async loadUser (a6.js:2:36)
    at async handler (a6.js:3:28)

then chain:
Error: Database connection failed
    at a6.js:5:65

The async/await version tells you the full path; the .then() version gives you one anonymous frame. When a crash keeps pointing at a low-level helper, converting the call path to async/await is often the fastest way to find which caller is responsible. Two other things help:

  • --trace-warnings prints stack traces for process warnings (such as PromiseRejectionHandledWarning, which appears when a .catch is attached too late).
  • --enable-source-maps makes stacks from TypeScript or bundled code point at the original files.

Fix at the framework boundary: Express 4 vs 5

Even after the individual bugs are fixed, you want one place where anything a route throws is turned into an HTTP response. Here is the same async route under both major Express versions:

app.get('/users/:id', async (req, res) => {
  await null;
  throw new Error('User not found');
});
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.message });
});
  • Express 5.x: the request gets 500 {"error":"User not found"}. Express 5 detects that the handler returned a rejected promise and calls next(err) for you.
  • Express 4.x: the error middleware never runs. The rejection is unhandled and, on modern Node, the whole process exits, taking every other in-flight request with it.

This is worth checking in your package.json. Express 5 became the latest tag on npm in 2025, but a lot of production code is still on 4.x, where each async route needs a wrapper:

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

app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await getUserData(req.params.id);
  res.json(user);
}));

With the wrapper, Express 4 returns the same 500 response as Express 5. Once errors reach one middleware, you can map domain errors to status codes there (a NotFoundError becomes 404, a ServiceUnavailableError becomes 503) instead of repeating try/catch in every route. Also avoid returning err.message to clients for unexpected errors; it can leak internal details, so send a generic message and log the details.


The global handlers, and what they really do

A process-level safety net is still useful, but it is easy to get subtly wrong:

process.on('unhandledRejection', (reason) => {
  console.log('stack:', reason.stack);
});
Promise.reject(undefined);
TypeError: Cannot read properties of undefined (reading 'stack')
    at process.<anonymous> (a4.js:1:77)

The handler itself crashed, because reason can be anything, including undefined. And there is a less obvious effect: under the default throw mode, registering an unhandledRejection listener stops Node from crashing. A listener that only logs means the process keeps running with whatever half-finished state the failed operation left behind:

process.on('unhandledRejection', (reason) => {
  console.log('logged:', reason?.message ?? reason);
});
Promise.reject(new Error('boom'));
setTimeout(() => console.log('process kept running'), 50);
// logged: boom
// process kept running

A safer pattern is to log defensively and then exit, letting the process manager (systemd, PM2, Kubernetes) start a clean instance:

process.on('unhandledRejection', (reason) => {
  const err = reason instanceof Error ? reason : new Error(`Non-error rejection: ${String(reason)}`);
  logger.fatal({ err }, 'unhandledRejection');
  server.close(() => process.exit(1));          // stop accepting, let in-flight requests finish
  setTimeout(() => process.exit(1), 10_000).unref(); // but do not hang forever
});

process.on('uncaughtException', (err) => {
  logger.fatal({ err }, 'uncaughtException');
  process.exit(1);
});

I have seen the logging-only version cause worse problems than the crash it replaced. A service stops restarting, the dashboards look calm, and meanwhile a failed rejection has left a database transaction open or a connection checked out of the pool. Some time later the pool is exhausted and every request hangs, and the only clue is a line in the logs from hours before. Crashing loudly is easier to debug than degrading quietly.

The global handler is a way to record bugs, not to recover from them. The real fixes are the ones in sections 3 to 7.


Monitoring: Sentry with the current SDK

Many tutorials, including an earlier version of this one, show Sentry.Handlers.requestHandler() and Sentry.Handlers.errorHandler(). Those were removed in Sentry’s JavaScript SDK v8; current versions do not export Handlers at all. The current setup initializes Sentry in its own file that is loaded before anything else, so its instrumentation can hook into modules like http and express:

// instrument.js
const Sentry = require('@sentry/node');

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 0.1, // 1.0 records every transaction; fine locally, expensive in production
});
// app.js
require('./instrument');                 // must be first
const Sentry = require('@sentry/node');
const express = require('express');

const app = express();
// ...routes...

Sentry.setupExpressErrorHandler(app);    // after all routes, before your own error middleware
app.use((err, req, res, next) => {
  res.status(500).json({ error: 'Internal server error' });
});

For ES modules, load it with node --import ./instrument.mjs app.mjs instead of require. The SDK also reports unhandled rejections by default, which is often how you first learn about the timing-dependent bugs from section 3: an error that appears in Sentry only a few times a day, always during a downstream incident.


A promise that never settles is the quiet cousin of one that rejects without a handler. For outbound HTTP, prefer the built-in abort signal:

const res = await fetch(url, { signal: AbortSignal.timeout(2000) });
// on timeout, rejects with an error whose name is 'TimeoutError'

For arbitrary promises, Promise.race works, but clear the timer so it does not keep the process alive, and remember that the losing promise keeps running; race does not cancel anything:

function withTimeout(promise, ms) {
  let timer;
  const timeout = new Promise((_, reject) => {
    timer = setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
  });
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}

Checklist for an intermittent unhandled rejection

In this kind of intermittent crash, the error almost never comes from the line in the stack trace. It comes from a promise that nothing was listening to at the moment it rejected. The checklist that finds it:

  1. Look for promises created before a try block and awaited after another await; replace with Promise.all/allSettled.
  2. Inside try, use return await, not return.
  3. Never pass async callbacks to forEach or other APIs that ignore return values.
  4. On Express 4, wrap async routes (or upgrade to Express 5).
  5. Reject with Error objects, convert hot paths to async/await for readable stacks, and keep a global handler that logs and exits.