Skip to content

UnhandledPromiseRejectionWarning: Causes and Fixes in Node.js

Why Node.js crashes with 'UnhandledPromiseRejectionWarning' or throws an uncaught exception, and how to fix and prevent unhandled promise rejections for good.

nodejs javascript-errors promises async-await unhandled-rejection event-loop nodejs-24 nodejs-26
Bharath G
Reading Progress

On This Page

1. The Error

If you're running anything older than a --unhandled-rejections=warn flag, or you're on any currently supported Node.js release without touching flags at all, an unhandled rejection doesn't print a friendly warning — it crashes the process:

/app/repro.js:3
    if (id !== 1) return reject(new Error(`user ${id} not found`));
                                ^

Error: user 2 not found
    at /app/repro.js:3:33
    at new Promise (<anonymous>)
    at getUser (/app/repro.js:2:10)
    at Object.<anonymous> (/app/repro.js:8:1)
    at Module._compile (node:internal/modules/cjs/loader:1705:14)
    at Object..js (node:internal/modules/cjs/loader:1838:10)
    at Module.load (node:internal/modules/cjs/loader:1441:32)
    at Function._load (node:internal/modules/cjs/loader:1263:12)
    at TracingChannel.traceSync (node:diagnostics_channel:328:14)
    at wrapModuleLoad (node:internal/modules/cjs/loader:237:24)

Node.js v22.22.2

That's a real trace, captured against Node 22.22.2 in this session. Notice the shape: it looks exactly like a synchronous throw — a code frame, a caret, an Error: line — except the at frames bottom out in node:internal/modules/cjs/loader, not in your catch block, because there never was one. The process exits with code 1.

If you explicitly opt back into the old behavior with --unhandled-rejections=warn, you get the warning generations of Node developers pasted into search boxes for years:

(node:1968) UnhandledPromiseRejectionWarning: Error: user 2 not found
    at /app/repro.js:3:33
    at new Promise (<anonymous>)
    at getUser (/app/repro.js:2:10)
    ...
(node:1968) UnhandledPromiseRejectionWarning: Unhandled promise rejection. 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().
To terminate the node process on unhandled promise rejection, use the CLI flag
`--unhandled-rejections=strict` (see
https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 2)

The message string is unchanged since it was introduced, but the default mode that produces it is not the default anymore — that's the part that trips people up when they compare notes with an old Stack Overflow answer.

In a browser, the same root cause — a promise that rejects with nothing attached to .catch() it — shows up in the console instead of crashing a process, since there's no process to crash:

Uncaught (in promise) Error: user 2 not found
    at getUser (app.js:2:23)
    at main (app.js:7:3)

Chrome and Firefox both use the Uncaught (in promise) prefix; Safari's WebKit console instead labels it as an unhandled promise rejection with the reason inline, though the underlying PromiseRejectionEvent fired on window is identical across engines. This article focuses on the Node.js server-side crash, with the browser event covered in Section 3 for comparison — the browser's opaque Failed to fetch and CORS failures are their own, separate topic.

Applies to: Node.js 18 through 26 (the "throw by default" behavior has been unchanged since Node 15), and to every evergreen browser for the unhandledrejection event.

2. How to Reproduce It

Node.js, CommonJS, current default behavior. Create repro.js:

// repro.js — CommonJS, Node 22/24/26, no package.json needed
function getUser(id) {
  return new Promise((resolve, reject) => {
    if (id !== 1) return reject(new Error(`user ${id} not found`));
    resolve({ id, name: 'Ada' });
  });
}

getUser(2).then((user) => console.log(user.name));
console.log('main continues...');

Run it:

node repro.js

main continues... prints first — the promise rejects asynchronously, after the current synchronous run of main() finishes — then Node throws the trace shown in Section 1 and exits with code 1. This is the single most common shape of this bug: a .then() with no paired .catch(), or equivalently, awaiting nothing (an unawaited async call whose returned promise is dropped).

The classic forEach trap, which produces the identical crash from async/await code instead of raw promises:

// repro-foreach.js — CommonJS, Node 22/24/26
const ids = [1, 2, 3];

async function getUser(id) {
  if (id !== 1) throw new Error(`user ${id} not found`);
  return { id, name: 'Ada' };
}

async function main() {
  ids.forEach(async (id) => {
    const user = await getUser(id); // rejection has no catcher — forEach ignores the returned promise
    console.log(user.name);
  });
}

main();

Array.prototype.forEach never awaits its callback's return value, so the promise getUser(2) produces (via the async function wrapper) rejects into the void. Swap forEach for a for...of loop with await inside it, or Promise.all(ids.map(...)), and the rejection becomes catchable again — that's the actual fix, covered in Section 6.

Triggering it, and seeing it, with explicit flags:

# Force the pre-Node-15 warn-only behavior (useful for reproducing old blog posts)
node --unhandled-rejections=warn repro.js

# See exactly where the rejecting promise was created
node --trace-warnings --unhandled-rejections=warn repro.js

# Force a hard crash even for rejections that *are* handled, but late
node --unhandled-rejections=strict repro.js

In a browser, save this as index.html and open it with npx serve . (needed so fetch-adjacent APIs and module scripts don't hit file:// restrictions):

<!doctype html>
<html>
<head><meta charset="utf-8"><title>unhandledrejection repro</title></head>
<body>
<script>
  window.addEventListener('unhandledrejection', (event) => {
    console.warn('Caught by listener:', event.reason.message);
    // event.preventDefault(); // uncomment to suppress the default console entry
  });

  function getUser(id) {
    return new Promise((resolve, reject) => {
      if (id !== 1) reject(new Error(`user ${id} not found`));
      else resolve({ id, name: 'Ada' });
    });
  }

  getUser(2).then((user) => console.log(user.name));
</script>
</body>
</html>

Open DevTools with "Pause on exceptions" and "Pause on caught exceptions" toggled to see the difference between a rejection your listener sees and one that also prints to the console (the default action, cancelable via event.preventDefault()).

3. Version / Environment Behavior Matrix

This is fundamentally a version-neutral topic — the default has been stable since Node 15 (2020) — but here's the current support picture, verified against nodejs.org/en/about/previous-releases and the v26.0.0 blog post rather than carried over from memory:

RuntimeUnhandled-rejection defaultStatus now (Sept 2026)
Node.js 18throw (crashes)End of life — no longer supported
Node.js 20throw (crashes)End of life as of 2026-04-30
Node.js 22throw (crashes)Maintenance LTS, EOL 2027-04-30
Node.js 24throw (crashes)Active LTS, EOL 2028-04-30
Node.js 26throw (crashes); V8 14.6, Temporal on by defaultCurrent since 2026-05-05; becomes Active LTS late October 2026
BrowserEventDefault console action
Chrome / Edge (V8)unhandledrejection on windowUncaught (in promise) <reason>
Firefox (SpiderMonkey)unhandledrejection on windowUncaught (in promise) <reason>
Safari (JavaScriptCore)unhandledrejection on windowUnhandled-rejection console entry with the reason inline

What changed and when: Node 6.6.0 first emitted a process warning for unhandled rejections; Node 7.0.0 marked not handling them as deprecated; PR #33021 flipped the default --unhandled-rejections mode from warn to throw in Node 15.0.0, and it has not changed since — 22, 24, and 26 all behave identically here. What changes next: nothing is currently proposed to alter this default; Node 27 (the first release under the new one-major-per-calendar-year schedule) inherits it unless nodejs/node lands a change before then — check nodejs/node's CHANGELOG before assuming otherwise on a future run.

4. Why It Happens — Surface Level

A promise's rejection is only "handled" if something is attached to it — a .catch(), the second argument to .then(), or a try/catch wrapped around an await of it — before the microtask queue finishes draining for that tick. If nothing is attached, Node considers the rejection unhandled and, by default, converts it into an uncaught exception that crashes the process. In the browser there's no process to crash, so the runtime instead fires a cancelable unhandledrejection event on window and logs it to the console.

The bug is almost always one of three shapes: a .then() chain missing its .catch(); an async function called without await and without a .catch() on the promise it returns (fire-and-forget that wasn't meant to be); or forEach, setInterval, or an event-handler callback marked async whose returned promise nobody is holding onto.

5. Why It Happens — Under the Hood

Promises settle through the job queue — what the spec calls microtasks — which Node and browsers both drain completely between each macrotask (a timer firing, an I/O callback, a rendering step). When you call .reject() or throw inside an async function, the engine doesn't immediately know whether anyone will ever call .catch() on that promise — attaching a handler is itself a normal, valid, asynchronous action that could happen on a later tick.

So V8 defers the decision. It marks the promise as "rejected, unhandled" and schedules a check after the current microtask checkpoint finishes — specifically, V8's promise-rejection tracker fires a PromiseRejectionEvents::kPromiseRejectWithNoHandler callback when a promise rejects with zero handlers attached, and a matching kPromiseHandlerAddedAfterReject callback if a .catch() shows up on a later tick before the check runs. Node's internal/process/promise_rejections.js hooks that tracker: it buffers pending unhandled rejections in a list, and only after the current microtask queue is fully drained does it walk that list and decide, per rejection, whether it is still unhandled. Only then does process.emit('unhandledRejection', reason, promise) fire — which is why a .catch() attached asynchronously-but-still-in-the-same-tick can rescue a promise that looked unhandled a moment earlier (and why a rejectionhandled event exists for the rare case where the catch arrives even later).

If nothing consumes the 'unhandledRejection' event (no process.on('unhandledRejection', ...) listener at all), Node's default --unhandled-rejections=throw mode takes that reason and runs it through the exact same triggerUncaughtException() path a synchronous throw would take — which is why the printed trace in Section 1 looks identical in shape to an ordinary uncaught exception: same formatter, same at frame renderer, same Node.js v22.22.2 footer, same process exit. The stack you see, though, is the stack at the point the promise was created or rejected — not a continuous call stack back to main() — because async stack frames don't survive an await boundary the way synchronous frames do; V8 stitches in the "zero-cost async stack trace" frames it captured at each await, which is why some frames further down the trace can look synthetic or truncated compared to a plain synchronous error.

In the browser, there's no triggerUncaughtException equivalent tied to a process, so the HTML spec instead defines this as firing a cancelable rejectionhandled/unhandledrejection pair of events on the global object; calling event.preventDefault() inside an unhandledrejection listener suppresses only the default console logging, not the underlying rejected-promise state.

6. The Fix

Quick fix — attach the missing handler. This is correct, not a band-aid, as long as you do something meaningful with the error:

// Before
getUser(2).then((user) => console.log(user.name));

// After
getUser(2)
  .then((user) => console.log(user.name))
  .catch((err) => console.error('getUser failed:', err.message));

async/await version — wrap the await, not the whole function:

// Before
async function main() {
  const user = await getUser(2);
  console.log(user.name);
}

// After
async function main() {
  try {
    const user = await getUser(2);
    console.log(user.name);
  } catch (err) {
    console.error('getUser failed:', err.message);
  }
}

The forEach trap — use for...of or Promise.all, not forEach:

// Before — forEach ignores the returned promise entirely
ids.forEach(async (id) => {
  const user = await getUser(id);
  console.log(user.name);
});

// After — sequential, and rejections are catchable
for (const id of ids) {
  try {
    const user = await getUser(id);
    console.log(user.name);
  } catch (err) {
    console.error(`getUser(${id}) failed:`, err.message);
  }
}

// After — concurrent, and Promise.all rejects (catchably) on the first failure
try {
  const users = await Promise.all(ids.map((id) => getUser(id)));
  users.forEach((user) => console.log(user.name));
} catch (err) {
  console.error('one of the lookups failed:', err.message);
}

Deliberate fire-and-forget — sometimes you genuinely don't want to await something (a best-effort analytics ping, say). Say so explicitly with a .catch() rather than leaving it bare:

// Explicit: this can fail, and we've decided that's fine, but we still observe it
sendAnalyticsPing(event).catch((err) => logger.warn({ err }, 'analytics ping failed'));

Last-resort safety net — a process-level handler. This should log and, in most cases, still exit, rather than swallow the problem:

process.on('unhandledRejection', (reason, promise) => {
  logger.error({ err: reason }, 'Unhandled promise rejection — exiting');
  // Give in-flight logs/telemetry a moment to flush, then die on purpose.
  // Do not just keep running: the process is now in an unknown state.
  process.exitCode = 1;
});

Do not use process.on('uncaughtException', ...) to try to catch these — it does not fire for unhandled rejections at all (they are a distinct event with a distinct default action); conflating the two is a common source of "my handler never runs" confusion.

Which fix to use: prefer the try/catch or .catch() fix at the call site every time you can identify the specific call that might fail — that's almost always the right answer, and it's where category 3 in Section 7 pushes you further. Reach for a process-level unhandledRejection handler only as a safety net that logs and exits cleanly, never as the primary error-handling strategy, and never to keep a process limping along after a rejection nobody understood.

7. Best Practices & The Better Design

The fundamentally better design is to treat "this promise might reject" the same way you treat "this function might throw": decide at the call site, not after the fact. Every await gets either a surrounding try/catch or an explicit acknowledgment that failure is acceptable (a .catch() with real handling in it, not an empty arrow function). Every array of promises that need to run concurrently goes through Promise.all, Promise.allSettled, or Promise.any — never through forEach, map without awaiting the result, or a bare loop of unawaited async calls.

// The right way — runs as written on Node 22/24/26, ESM
import { setTimeout as delay } from 'node:timers/promises';

async function fetchUserSafely(id) {
  try {
    return await getUser(id);
  } catch (err) {
    return { id, name: null, error: err.message };
  }
}

async function main() {
  const results = await Promise.allSettled([1, 2, 3].map(fetchUserSafely));
  for (const result of results) {
    if (result.status === 'fulfilled') console.log(result.value);
  }
}

main().catch((err) => {
  console.error('main() crashed unexpectedly:', err);
  process.exitCode = 1;
});

Note the outer .catch() on main() itself — even a carefully written async entry point can still reject if something inside genuinely wasn't anticipated, and that top-level call is exactly where a stray unhandled rejection tends to originate in real codebases. Pair this with typed error classes and error.cause chains (own topic) so a caught rejection tells you why it happened, not just that it did, and with AbortSignal.timeout() on outbound calls so a hung request doesn't sit unresolved indefinitely before it ever gets the chance to reject.

8. How to Prevent It Long-Term

  • Lint for it before it ships: @typescript-eslint/no-floating-promises and no-misused-promises catch exactly this class of bug at the call site — a promise-returning expression used as a statement, or passed somewhere a non-async callback was expected (like Array.prototype.forEach). Turn both on as errors, not warnings.
  • Type-check for it: tsc --noEmit --strict won't catch a floating promise by itself, but combined with the ESLint rules above and noUncheckedIndexedAccess, it closes most of the surrounding gaps that produce undefined rejections in the first place.
  • Test the failure path, not just the happy path: write at least one test per async boundary that forces a rejection and asserts your code actually catches it — a Promise.reject() stub is enough; you don't need a real failing dependency.
  • Run with the strictest flag in CI: add a CI job with NODE_OPTIONS=--unhandled-rejections=strict so any rejection your test suite triggers but doesn't handle fails the build loudly, instead of quietly logging a warning that scrolls past in CI output.
  • Watch it in production: track unhandledRejection volume the same way you track uncaught exceptions in Sentry/Datadog/whatever APM you run — a nonzero, nonzero-growing rate of these in production is a leading indicator of a crash-looping process, not a cosmetic warning.
  • Never swallow with an empty catch {} — if you must catch and ignore, say why in a comment and still log at debug level; a silent catch here just relocates the bug from "visible crash" to "silent data loss," which is strictly worse.

9. Key Takeaways

  • Since Node 15, an unhandled promise rejection crashes the process by default (--unhandled-rejections=throw) — it is not merely a warning anymore, whatever an old blog post says.
  • Array.prototype.forEach with an async callback is the single most common way developers accidentally produce an unhandled rejection from await-looking code.
  • process.on('uncaughtException', ...) does not catch unhandled rejections — they are a distinct event with a distinct default action; use process.on('unhandledRejection', ...) only as a logging safety net, not a primary handler.
  • Fix it at the call site: pair every await with a try/catch, every .then() with a .catch(), and every batch of concurrent promises with Promise.all/allSettled/any — never a bare forEach.
  • The browser's equivalent, the cancelable unhandledrejection event on window, fires for the same root cause but can't crash a process — it logs Uncaught (in promise) ... to the console instead.
nodejsjavascript-errorspromisesasync-awaitunhandled-rejectionevent-loopnodejs-24nodejs-26

From aspiring developer to blogger, I test learning platforms, simplify programming syntax, and share resources that work. Helping you code smarter as I grow myself. New or experienced, you're welcome here.

Comments