Ch. 1 · JavaScript

Uncaught (in promise): Fixing Unhandled Promise Rejections

Fix Uncaught (in promise) errors and Node crashes from unhandled rejections: missing await, async forEach, fire-and-forget and rethrown errors.

~7 min readintermediateupdated Oct 4, 2026

A promise rejected and nobody was listening. In Chrome and Edge the console shows:

Uncaught (in promise) Error: config.json not found
Text

Firefox prints the same Uncaught (in promise) prefix; Safari says Unhandled Promise Rejection: Error: config.json not found. In Node.js 15 and later the process prints the error’s stack and exits with code 1. If the rejection reason is not an Error object, Node 22 prints:

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'
Text

Either way, the meaning is the same: an async operation failed, and no await inside a try, and no .catch(), was attached to that promise in time.

Quick fix checklist

  • Find the promise named in the stack trace and check that something awaits it or calls .catch() on it.
  • Look for async callbacks passed to forEach, event emitters or timers: their returned promises are thrown away.
  • Look for calls to async functions without await (“fire-and-forget”).
  • Check .catch() handlers that log and then throw again: the new rejection also needs a handler.
  • Use for...of with await for sequential work, or Promise.all/Promise.allSettled for concurrent work.
  • Keep process.on('unhandledRejection') or window.onunhandledrejection for logging only.

Before you start

You should know that an async function always returns a promise, that throw inside it becomes a rejection of that promise, and that await unwraps a promise and throws its rejection reason at that line. The examples run as ES modules (.mjs) in Node.js 18 or newer; output was captured on Node 22.

Why it happens

A rejected promise holds its error until someone handles it. The engine tracks rejected promises that have no handler. After the current job finishes and the microtask queue drains, any still unhandled is reported. In browsers that means a console error and an unhandledrejection event. In Node, the default --unhandled-rejections=throw mode re-raises it as an uncaught exception, which ends the process:

async function loadConfig() {
  throw new Error('config.json not found');
}
loadConfig(); // no await, no catch
setTimeout(() => console.log('still running?'), 100);
JavaScript
Error: config.json not found
    at loadConfig (file:///app/crash.mjs:2:9)
    at file:///app/crash.mjs:4:1

Node.js v22.23.2
Text

still running? never prints; the exit code is 1. Before Node 15, the default only printed an UnhandledPromiseRejectionWarning and kept running, which is why older tutorials treat this as harmless.

The patterns that cause it:

Missing await. loadConfig() above starts the work and returns a promise that nobody holds. A try/catch around a call without await does not help, because the call itself returns successfully; the rejection happens later.

forEach with an async callback. forEach calls your callback, ignores the returned promise and moves on. The surrounding try/catch has already finished by the time a save fails:

const ids = [1, 2, 3];
async function save(id) {
  if (id === 2) throw new Error(`save failed for ${id}`);
  return id;
}
async function saveAll() {
  try {
    ids.forEach(async (id) => { await save(id); });
    console.log('saveAll finished');
  } catch (err) {
    console.log('caught:', err.message); // never runs
  }
}
await saveAll();
// saveAll finished
// (then the process crashes with Error: save failed for 2)
JavaScript

Rethrowing in .catch(). A .catch() handler returns a new promise. If the handler throws, that new promise rejects, and it needs its own handler:

function fetchReport() {
  return Promise.reject(new Error('report service down'));
}
fetchReport().catch((err) => {
  console.log('logged:', err.message);
  throw err; // a new rejected promise that nobody handles
});
// logged: report service down
// (then the process crashes with Error: report service down)
JavaScript

Rethrowing is correct inside a function whose caller awaits it. At the top of a chain, it just moves the unhandled rejection one step along.

Attaching the handler too late. “Unhandled” is decided when the microtask queue drains, not “eventually”. In Node 22 this still crashes, even though a handler arrives a moment later:

const p = Promise.reject(new Error('late'));
setTimeout(() => p.catch((e) => console.log('handled late:', e.message)), 0);
// (crashes with Error: late before the timer fires)
JavaScript

Step-by-step walkthrough

Step 1: Reproduce and read the stack

Node prints the stack of the original error, so the top frames point at the throw or the rejected call. In the forEach case, the trace includes at Array.forEach (<anonymous>) directly below your callback, which is a strong hint. In browsers, click the source link next to Uncaught (in promise); enabling “Pause on uncaught exceptions” in the Sources panel stops right at the throw.

Step 2: Find who should have handled it

Walk outwards from the throwing function and ask, for each caller: does this caller await the promise, return it to its own caller, or attach .catch()? The first caller that does none of these is where the chain breaks. Typical culprits are array callbacks, event listeners, timers and constructors (which cannot be async).

Step 3: Restore the chain

Give every promise an owner. Use for...of with await when the operations must run one by one, and Promise.all (fail fast) or Promise.allSettled (collect every outcome) when they can run concurrently:

const ids = [1, 2, 3];
async function save(id) {
  if (id === 2) throw new Error(`save failed for ${id}`);
  return id;
}

// Sequential: the loop awaits each save, so try/catch sees the failure.
async function saveAllSequential() {
  try {
    for (const id of ids) await save(id);
    console.log('all saved');
  } catch (err) {
    console.log('sequential caught:', err.message);
  }
}

// Concurrent: allSettled waits for every save and reports each outcome.
async function saveAllConcurrent() {
  const results = await Promise.allSettled(ids.map(save));
  const failed = results.filter((r) => r.status === 'rejected');
  console.log(`concurrent: ${results.length - failed.length} saved, ${failed.length} failed`);
  for (const f of failed) console.log('  reason:', f.reason.message);
}

await saveAllSequential();
await saveAllConcurrent();
// sequential caught: save failed for 2
// concurrent: 2 saved, 1 failed
//   reason: save failed for 2
JavaScript

Step 4: Handle deliberate fire-and-forget explicitly

Sometimes you really do not want to wait, for example sending analytics. Then attach a handler on the spot: sendAnalytics(event).catch(reportError);. The intent is visible, and failures go somewhere useful.

Step 5: Add a last-resort logger

Register a global hook so anything that still slips through is recorded with context:

process.on('unhandledRejection', (reason, promise) => {
  console.error('unhandled rejection:', reason instanceof Error ? reason.message : reason);
  process.exitCode = 1; // keep the failure visible to whoever started the process
});
Promise.reject(new Error('cache warmup failed'));
setTimeout(() => console.log('process kept running'), 20);
// unhandled rejection: cache warmup failed
// process kept running
JavaScript

Registering this listener replaces the default crash. That is why the example sets process.exitCode; for a server, logging and then shutting down gracefully is usually safer than carrying on in an unknown state. In browsers, the equivalent is window.addEventListener('unhandledrejection', (event) => report(event.reason)).

Worked scenario

A Node script imports products from a CSV file and calls an API for each row. It sometimes dies halfway with an unhandled rejection, and the team cannot tell which rows were imported:

// Broken (illustrative): rows is an array of parsed CSV rows
rows.forEach(async (row) => {
  await api.upsertProduct(row);
});
console.log('import complete');
JavaScript

Diagnosis. import complete prints immediately, before any upsert finishes. When one upsert rejects (say, a validation error on row 214), nothing is awaiting it, so Node crashes with exit code 1 while other requests are still in flight. The stack shows Array.forEach under the callback.

Fix. Decide what a failure should mean. Here the business wants every valid row imported and a report of the failures, so Promise.allSettled fits. For thousands of rows you would also cap concurrency, but the error handling stays the same:

// Fixed (illustrative)
const results = await Promise.allSettled(rows.map((row) => api.upsertProduct(row)));
const failures = results
  .map((r, i) => ({ r, row: rows[i] }))
  .filter(({ r }) => r.status === 'rejected');
console.log(`imported ${rows.length - failures.length} of ${rows.length}`);
for (const { r, row } of failures) console.error(`row ${row.sku}: ${r.reason.message}`);
if (failures.length > 0) process.exitCode = 1;
JavaScript

Common mistake

The most tempting wrong fix is registering process.on('unhandledRejection', () => {}) (or an empty .catch(() => {})) to stop the crash. That turns a loud, located failure into silent data loss: the import above would “succeed” with missing rows and exit code 0.

The second is adding try/catch around code that does not await. It looks protective, but it only catches synchronous throws. If the promise is not awaited inside the try block, its rejection happens after the catch has finished.

Verify the behavior

Assert that the function now rejects or reports, instead of crashing the process:

import assert from 'node:assert/strict';

async function save(id) {
  if (id === 2) throw new Error(`save failed for ${id}`);
  return id;
}
async function saveAll(ids) {
  for (const id of ids) await save(id);
}

await assert.rejects(saveAll([1, 2, 3]), /save failed for 2/);
await assert.doesNotReject(saveAll([1, 3]));
console.log('failures reach the caller');
// failures reach the caller
JavaScript

Then run the script and check echo $? prints 0 on success. In browsers, reload with the console open: no Uncaught (in promise) lines should appear, and failures should show up in your UI or logging instead.

Interview exercise

“Why doesn’t this catch block run, and what are two ways to fix it?”

async function notifyAll(users) {
  try {
    users.forEach(async (u) => await sendEmail(u));
  } catch (e) {
    console.error('notify failed', e);
  }
}
JavaScript

Answer and reasoning

forEach invokes the async callback for each user and discards the promise each call returns. notifyAll therefore completes its try block synchronously, before any email is sent, and the catch is no longer active when a sendEmail promise rejects. Each rejection has no handler, so it becomes an unhandled rejection (and crashes a Node process by default). Fix one: for (const u of users) await sendEmail(u); inside the try, which sends sequentially and stops at the first failure. Fix two: await Promise.all(users.map(sendEmail)), which sends concurrently and rejects with the first failure, or Promise.allSettled if every user should be attempted and failures reported. I would choose based on whether partial success is acceptable and how many concurrent requests the email provider tolerates. A global unhandledRejection handler would not be a fix: the function would still claim success.

Continue learning

Keep practising with the JavaScript interview questions and the JavaScript MCQs. For choosing between all, allSettled, any and race, see promise combinators; for looping patterns, async array loops; and for when the microtask queue drains, the event loop, microtasks and macrotasks. Official references: Node’s unhandledRejection event, the –unhandled-rejections modes and MDN’s unhandledrejection event.

More in JavaScript

read ✓JavaScript · hard

JavaScript Timeouts with Promise.race

Race a promise against a timer to enforce a deadline, and clean up the timer plus the losing work instead of leaving it running.

~3 min readread →
read ✓JavaScript · hard

The Event Loop: Microtasks vs Macrotasks

How the call stack, task queue and microtask queue fit together, where rendering happens, how async/await schedules work, and output puzzles with answers.

~7 min readread →
esc