Skip to content

Fix: UnhandledPromiseRejectionWarning / UnhandledPromiseRejection

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix UnhandledPromiseRejectionWarning in Node.js and unhandled promise rejection errors in JavaScript caused by missing catch handlers, async/await mistakes, and event emitter errors.

The Microtask That Slipped Past Every Catch

Personally, this is one of the JavaScript errors I have the strongest opinions about. The fix is rarely the line the warning points at; it is almost always a missing await or .catch() somewhere in the same callstack. I now treat any “UnhandledPromiseRejection” in a code review as a sign that the surrounding async layer needs a re-read. You run a Node.js script or browser app and see:

UnhandledPromiseRejectionWarning: Error: connect ECONNREFUSED 127.0.0.1:5432
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().

Or in newer Node.js versions (v15+):

node:internal/process/promises:279
            triggerUncaughtException(err, true /* fromPromise */);
            ^

[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: Error: connect ECONNREFUSED]

Or in the browser console:

Uncaught (in promise) TypeError: Failed to fetch
Uncaught (in promise) Error: Request failed with status code 401

The error means a Promise rejected (failed) but nothing handled that rejection; there was no .catch(), no try/catch around await, and no rejection handler attached to the Promise.

Note: In Node.js v15+, an unhandled promise rejection crashes the process by default. In older versions it printed a warning but kept running. If your process is crashing unexpectedly after an upgrade, this is a likely cause.

Quick Reference Before You Dive In

If you arrived here from Google with a fresh warning, the five facts that resolve roughly 90 percent of cases:

  1. Node.js v15+ CRASHES the process on unhandled rejection by default. Older Node versions only printed a warning. The Node.js unhandledRejection docs and the MDN unhandledrejection event are the canonical references.
  2. The fix is rarely at the line where the warning fires. It is upstream: a missing await, a missing .catch(), or an async function called as fire-and-forget. The warning shows you the rejected promise; you need to find the call site.
  3. Inside async functions, use try / catch around await. try / catch catches both promise rejections and synchronous throws. It is the cleanest pattern; reach for it before chaining .catch().
  4. Promise.allSettled() is the right tool when partial failures are acceptable. Promise.all() rejects on the first failure; allSettled always resolves with an outcome array. Use the latter for “load whatever loads.”
  5. A process.on("unhandledRejection") handler is a safety net, NOT a fix. It logs leakages but does not address the root cause. Add it to production servers AND fix the underlying code.

The rest of this article walks through each cause in detail, plus the failure modes most other guides skip.

How the Rejection Detector Actually Works

Every Promise can be in one of three states: pending, fulfilled, or rejected. When a Promise rejects (a network request fails, a database query errors, or you explicitly call reject()) the runtime looks for a rejection handler attached to that promise either via .catch(), the second argument to .then(), or a surrounding try/catch around await. If it finds one, the rejection is “handled” and execution continues normally. If it does not find one within the same microtask tick, the runtime queues an unhandledRejection event.

The window between “rejection happens” and “rejection is checked for a handler” is a single microtask. That is what makes this class of bug subtle. If you create a promise, do not attach any handler, and then attach a handler later (in a setTimeout, in another async function), the runtime has already fired unhandledRejection by then. Whatever you do later does not retroactively un-fire the warning. This is also why “rescue” patterns that attach .catch asynchronously still produce the warning.

The behavior on an unhandled rejection has changed significantly across Node.js versions. Node 10 and earlier emitted only a deprecation warning. Node 14 changed --unhandled-rejections=warn to be more visible. Node 15 made the default --unhandled-rejections=throw, which terminates the process with a non-zero exit code. This is the change that catches teams off guard: code that ran for years on Node 12 with logged warnings suddenly crashes on Node 16. There is no flag to revert to silent ignore in modern Node; the closest is --unhandled-rejections=warn, which downgrades the crash to a warning.

Common causes:

  • Missing .catch() on a promise chain.
  • await without try/catch inside an async function.
  • Async function called without await: the returned promise is ignored.
  • Event handlers that are async: errors thrown inside are not caught by the caller.
  • Promise.all() with no error handler: one rejection kills all.
  • Forgetting to return a promise inside .then(), breaking the chain.

Platform and Environment Differences

Unhandled rejection behavior differs significantly across JavaScript runtimes. The same code can be silent on one and fatal on another.

Node.js v15+. Unhandled rejections terminate the process by default. The flag --unhandled-rejections=throw is the default. To keep the older warning-only behavior on a single execution, pass --unhandled-rejections=warn. The most permissive option is --unhandled-rejections=none, which suppresses the warning entirely (not recommended). Setting process.on('unhandledRejection', ...) overrides the default termination for that listener; this is how production servers typically log and continue rather than crash. See Fix: Node Unhandled Rejection Crash for the specific Node 15+ crash flow.

Node.js v14 and earlier. Unhandled rejections printed UnhandledPromiseRejectionWarning and a deprecation message about future Node versions terminating the process. The process kept running. Code that worked on Node 12 in production may crash on a Node 18 upgrade; audit promise chains before upgrading.

Browsers. Modern browsers fire unhandledrejection on window. Chrome and Firefox log “Uncaught (in promise)” to the console at error level. Safari logs at warning level. None of them terminate the page. To intercept, attach window.addEventListener('unhandledrejection', ...) and call event.preventDefault() to suppress the console message. Error monitoring services (Sentry, Datadog RUM, LogRocket) typically install this listener automatically.

Bun. Bun follows Node’s behavior in defaulting to terminate-on-unhandled, but Bun ships several test framework integrations that swallow the rejection during test runs. If a test imports a module whose top-level code creates an unhandled rejection, Bun emits a warning but may continue the test suite. Code that relies on Bun’s leniency will fail in production Node deployments.

Deno. Deno also terminates on unhandled rejections by default. The flag is --unhandled-rejection-mode=warn and follows the same semantics as Node’s. Deno’s permission model adds a wrinkle: a rejected promise from a permission denial (no --allow-net) becomes an unhandled rejection if the calling code did not check.

Jest test environments. Jest 26+ logs unhandled rejections as warnings during test runs but does not fail the test by default. Jest 29 changed this so that a rejection emitted during a test fails that test. This is good (it surfaces bugs) but it means tests that previously passed silently can fail after a Jest upgrade. The mock environments (jsdom, node) handle rejections differently: under jsdom, the rejection goes through the simulated window.onunhandledrejection; under node, it goes through process.on('unhandledRejection'). If your test setup file attaches a listener, it must target the correct environment.

Vitest. Vitest follows Node’s --unhandled-rejections setting because it runs in Node. It fails the test on unhandled rejections by default and prints a stack trace pointing at the rejected promise’s origin.

Edge runtimes (Cloudflare Workers, Vercel Edge). These runtimes do not have a process object. Use addEventListener('unhandledrejection', ...) on the global scope. An unhandled rejection in a Worker terminates the request; the Worker returns a 500 but the underlying process stays alive to serve future requests.

Workers in the browser. Web Workers and Service Workers each have their own global scope and their own unhandledrejection event. A rejection in a Worker does not propagate to the parent window’s listener; you must attach one inside the Worker script. Service Worker rejections during install or activate events are particularly tricky: they can prevent the Service Worker from activating without obvious error in the parent page.

When to Use Which Fix

The next seven sections cover the fixes in detail. The table below maps your situation to the recommended fix.

Your situationRecommended fixWhy
Using .then() chains, no .catch()Fix 1: .catch() at the END of the chainCatches errors from any earlier link
Using async / awaitFix 2: try / catch around awaitCatches both rejections and sync throws
Calling async function as fire-and-forgetFix 3: .catch() it, or make caller async + awaitReturned promise must be handled
Promise.all([...]) with no error handlingFix 4: try / catch around await Promise.all()First rejection kills the whole batch
Partial failures acceptableFix 4 variant: Promise.allSettled()Returns all outcomes, never rejects
Want safety net for missed rejectionsFix 5: process.on("unhandledRejection")Logs leakage to monitoring
EventEmitter listener is asyncFix 6: try / catch inside, emit error eventEE does not propagate listener errors
Want compile-time preventionFix 7: ESLint no-floating-promisesCatches missing await at lint time

If multiple rows apply, pick the topmost one.

Fix 1: Add .catch() to Promise Chains

Every .then() chain needs a .catch() at the end:

Broken:

fetch("/api/data")
  .then(res => res.json())
  .then(data => console.log(data));
// If fetch fails, the rejection is unhandled

Fixed:

fetch("/api/data")
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error("Fetch failed:", err));

The .catch() must be at the end of the chain. Adding it only to the first .then() will not catch errors thrown in later .then() callbacks.

Return promises inside .then() to keep the chain intact:

// Broken: inner promise is orphaned
fetch("/api/users")
  .then(res => {
    fetch("/api/posts"); // Missing return: this promise is orphaned
  })
  .catch(err => console.error(err)); // Does NOT catch errors from /api/posts

// Fixed
fetch("/api/users")
  .then(res => {
    return fetch("/api/posts"); // Returning keeps it in the chain
  })
  .catch(err => console.error(err));

Fix 2: Wrap await in try/catch

When using async/await, wrap calls that can fail in try/catch:

Broken:

async function loadUser(id) {
  const res = await fetch(`/api/users/${id}`); // Can reject
  const user = await res.json();               // Can also throw
  return user;
}

loadUser(123); // No await, no catch: rejection goes unhandled

Fixed:

async function loadUser(id) {
  try {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const user = await res.json();
    return user;
  } catch (err) {
    console.error("Failed to load user:", err);
    throw err; // Re-throw if the caller needs to handle it
  }
}

// Always await or catch the async function's result
try {
  const user = await loadUser(123);
} catch (err) {
  // Handle here
}

One reason I default to try / catch instead of .catch() in async functions: a single try block catches both promise rejections from await AND synchronous throws from the function body. With .catch() you need to remember to handle the sync path separately. I treat try / catch around await as the single tool for both cases and reach for .catch() only when chaining promises directly.

Fix 3: Always await or catch async function calls

When you call an async function, it returns a Promise. If you do not await it or chain .catch(), any rejection is unhandled:

Broken:

async function sendEmail(to, subject) {
  const result = await emailService.send(to, subject);
  return result;
}

// In an event handler or fire-and-forget call:
button.addEventListener("click", () => {
  sendEmail("[email protected]", "Welcome"); // Missing await AND .catch()
});

Fixed: add .catch():

button.addEventListener("click", () => {
  sendEmail("[email protected]", "Welcome")
    .catch(err => console.error("Email failed:", err));
});

Fixed: make the handler async:

button.addEventListener("click", async () => {
  try {
    await sendEmail("[email protected]", "Welcome");
  } catch (err) {
    console.error("Email failed:", err);
  }
});

Note: Express.js route handlers do not automatically catch async errors. Wrap them or use an async wrapper utility:

// Broken: Express doesn't catch async rejections automatically (Express 4)
app.get("/users", async (req, res) => {
  const users = await db.getUsers(); // If this rejects, Express hangs
  res.json(users);
});

// Fixed: wrap with try/catch
app.get("/users", async (req, res, next) => {
  try {
    const users = await db.getUsers();
    res.json(users);
  } catch (err) {
    next(err); // Pass to Express error handler
  }
});

Express 5 (currently in beta) handles async errors automatically.

Fix 4: Handle Promise.all() Rejections

Promise.all() rejects as soon as any single Promise in the array rejects:

Broken:

async function loadDashboard() {
  const [users, posts, stats] = await Promise.all([
    fetchUsers(),
    fetchPosts(),
    fetchStats(), // If this rejects, the whole Promise.all rejects
  ]);
}

loadDashboard(); // No catch

Fixed:

async function loadDashboard() {
  try {
    const [users, posts, stats] = await Promise.all([
      fetchUsers(),
      fetchPosts(),
      fetchStats(),
    ]);
    return { users, posts, stats };
  } catch (err) {
    console.error("Dashboard load failed:", err);
    throw err;
  }
}

If you want all results even if some fail, use Promise.allSettled():

const results = await Promise.allSettled([fetchUsers(), fetchPosts(), fetchStats()]);

results.forEach((result, i) => {
  if (result.status === "fulfilled") {
    console.log(`Request ${i} succeeded:`, result.value);
  } else {
    console.error(`Request ${i} failed:`, result.reason);
  }
});

The reason this matters: Promise.allSettled() always resolves with an array of outcome objects; it never rejects. Use it when partial failures are acceptable and you want all available data. The classic case is a dashboard that loads three widgets independently; with Promise.all one slow API breaks the whole page, with allSettled the other two render normally.

Fix 5: Add a Global Unhandled Rejection Handler

As a safety net (not a replacement for proper error handling) add a global handler to catch any rejections that slip through:

Node.js:

process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise, "reason:", reason);
  // Log to error tracking service (Sentry, Datadog, etc.)
  // Optionally exit: process.exit(1);
});

Browser:

window.addEventListener("unhandledrejection", (event) => {
  console.error("Unhandled promise rejection:", event.reason);
  event.preventDefault(); // Suppress the browser's default console error
});

Add this near the top of your entry file. It is useful for catching rejections you may have missed, but the root fix is always to add proper .catch() or try/catch to the originating code.

Fix 6: Fix Async Event Emitter Errors

EventEmitter callbacks in Node.js do not automatically propagate errors from async handlers:

Broken:

const EventEmitter = require("events");
const emitter = new EventEmitter();

emitter.on("data", async (data) => {
  await processData(data); // If this rejects, the error is unhandled
});

Fixed:

emitter.on("data", async (data) => {
  try {
    await processData(data);
  } catch (err) {
    emitter.emit("error", err); // Route to the error event
  }
});

emitter.on("error", (err) => {
  console.error("Emitter error:", err);
});

Always add an "error" listener to EventEmitters. An unhandled "error" event in Node.js throws an exception.

Fix 7: Find All Unhandled Rejections in Your Codebase

Search your code for async patterns that lack error handling:

# Find async functions that might be called without await or catch
grep -rn "async function\|async (" src/ --include="*.js" --include="*.ts"

# Find .then() calls without a following .catch()
grep -rn "\.then(" src/ --include="*.js" | grep -v "\.catch("

Use ESLint rules to enforce promise handling automatically:

{
  "rules": {
    "no-floating-promises": "error",
    "@typescript-eslint/no-floating-promises": "error",
    "promise/catch-or-return": "error"
  }
}

The @typescript-eslint/no-floating-promises rule catches unhandled async calls at lint time, before they become runtime errors. For related ESLint issues, see Fix: ESLint Parsing Error: Unexpected token.

Stranger Causes I Have Tracked Down

Check for rejection in a constructor. You cannot use await in a constructor. If you call an async function inside a constructor without handling its result, you get an unhandled rejection. Move async initialization to a static factory method or an init() method called after construction.

Check third-party library callbacks. Some libraries accept callbacks that they call synchronously or asynchronously. If you pass an async callback and the library does not handle the returned Promise, rejections go unhandled. Wrap the callback body in try/catch and handle errors manually.

Check for race conditions with setTimeout or setInterval. Async functions called inside timers run outside any surrounding try/catch. Each timer callback needs its own error handling.

Use async stack traces. In Node.js, set --async-stack-traces (enabled by default in v12+) or in Chrome DevTools enable “Async” in the Call Stack panel. This shows you where the Promise was created, not just where it rejected, which makes the origin much easier to trace.

Check top-level module side effects. Some libraries kick off network calls or file reads at import time. If their internal promise rejects (DNS down, file missing) and they did not .catch() it, your application crashes during import even though your code touched nothing. The fix is to import these libraries inside a try/catch of an async bootstrap function, or to set a process.on('unhandledRejection') handler before the first require or import that does third-party work.

Check for promises rejected with non-Error values. Code like Promise.reject('something') or reject(null) produces an unhandled rejection with an empty stack; the diagnostic message you see is generic and unhelpful. Always reject with a real Error object so the stack trace points at the rejection site. ESLint’s prefer-promise-reject-errors rule catches this.

Check Express async error handling. Express 4 does not automatically catch async errors in route handlers; an unhandled rejection from await db.query(...) propagates as an unhandledRejection on the process, not as a 500 response. Use express-async-errors (a require-time patch) or Express 5 (which handles this natively). See Fix: Express Async Error for the specific pattern.

Check for --unhandled-rejections set in a parent process. If your service is launched by a wrapper script that passes NODE_OPTIONS=--unhandled-rejections=throw, it overrides whatever your code expects. Run node -e "console.log(process.execArgv)" and inspect NODE_OPTIONS to confirm.

What Other Tutorials Get Wrong About This Error

Most JavaScript async tutorials list the same fixes but frame them in ways that produce subtle bugs.

They recommend process.on("unhandledRejection") as the fix. It is a safety net for production logging, not a root-cause fix. Tutorials that present the global handler as “the solution” let readers paper over real bugs that should have a try / catch at the source.

They omit the Node v15+ crash behavior change. Articles written for Node 12 / 14 era code present unhandled rejection as “just a warning.” On Node 16+ it crashes the process by default. Readers who copy those patterns ship code that runs locally and crashes in production after a Node upgrade.

They show .catch() only on the first .then(). Errors from later .then() callbacks are not caught. The .catch() MUST be at the end of the chain (or after every .then() whose body can throw). Many tutorials show partial chains and call them “handled.”

They miss the missing-return orphan trap. Inside .then(res => { fetch("/other"); }), the inner fetch’s promise is orphaned because nothing returns it; the outer chain’s .catch() does not see its rejection. Tutorials that show nested fetches without explaining the return rule produce code where some rejections silently leak.

They confuse Promise.all and Promise.allSettled. Promise.all rejects on the first failure; Promise.allSettled always resolves with outcomes. Articles that present them as interchangeable miss that the right choice depends on whether partial failures are acceptable.

They omit Express 4 async-handler caveat. Express 4 does not catch async rejections in route handlers by default. The rejection propagates as an unhandledRejection on the process; the client sees a hanging request. Many “Express + async / await” tutorials show the broken pattern without warning.

Frequently Asked Questions

Why did my code start crashing after upgrading Node.js?

Node v15+ changed the default behavior of unhandled rejections from “warn and continue” to “crash the process.” Code that ran for years on Node 12 with logged warnings now exits with a non-zero code on Node 16+. Audit your promise chains for missing .catch() / try / catch, then either fix the underlying code (preferred) or add a process.on("unhandledRejection") handler that logs and continues (production safety net).

What is the difference between .catch() and try / catch?

.catch() is a promise method that handles rejections of the promise chain it terminates. try / catch is a syntax block that catches both promise rejections (from await) AND synchronous throws inside the block. Inside async functions, prefer try / catch because it handles both. For non-async code using .then() chains, use .catch() at the end.

Why does my process.on("unhandledRejection") not catch some rejections?

The handler fires after a microtask delay. If you attach a .catch() to the promise within that microtask window (synchronously, in the same tick), the rejection is “handled” before the global event fires. The handler only sees promises that had NO handler attached within one microtask.

Should I use Promise.all or Promise.allSettled?

Promise.all when ALL operations must succeed (e.g., reading three required files before processing). Promise.allSettled when partial success is acceptable (e.g., loading dashboard widgets that can fail independently). Promise.all rejects on the first failure; allSettled returns outcomes for all promises regardless.

Is await inside a for loop OK or should I always use Promise.all?

Depends on intent. await inside for runs sequentially; Promise.all([...arr.map(asyncFn)]) runs concurrently. If the operations are independent and order does not matter, Promise.all is faster. If each iteration depends on the previous result (or you need to rate-limit), sequential await is correct.

Why does Express 4 not catch async route handler errors?

Express 4 pre-dates async / await. Route handlers are expected to call next(err) to propagate errors; an async function’s rejection is not automatically passed to next. Use express-async-errors (a one-line patch) or upgrade to Express 5, which handles this natively. The middleware-only fix is to wrap every async handler with a small adapter that pipes its rejection to next.

For errors thrown inside .then() callbacks that look like TypeError: x is not a function, the unhandled rejection wraps the underlying TypeError; fix the inner error first.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles