Fix: UnhandledPromiseRejectionWarning / UnhandledPromiseRejection
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 Error
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 401The 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.
Why This Happens
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. awaitwithouttry/catchinside anasyncfunction.- 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
returna 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.
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 unhandledFixed:
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 unhandledFixed:
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
}Pro Tip: A
try/catcharoundawaitcatches both Promise rejections and synchronous errors thrown inside the async function. It replaces both.catch()and a regulartry/catchin most cases.
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 catchFixed:
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);
}
});Why 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.
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.
Still Not Working?
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 — much easier to trace the origin.
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/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.
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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Node.js Stream pipeline() Not Working — Backpressure, Error Propagation, AbortSignal, and Web Streams Interop
How to fix Node.js stream/promises pipeline errors — uncaught stream errors, backpressure ignored, AbortSignal not propagating, async iterators in pipeline, Transform stream object mode, and converting between Node and Web Streams.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: Bun Not Working — Node.js Module Incompatible, Native Addon Fails, or bun test Errors
How to fix Bun runtime issues — Node.js API compatibility, native addons (node-gyp), Bun.serve vs Node http, bun test differences from Jest, and common package incompatibilities.
Fix: Node.js Stream Error — Pipe Not Working, Backpressure, or Premature Close
How to fix Node.js stream issues — pipe and pipeline errors, backpressure handling, Transform streams, async iteration, error propagation, and common stream anti-patterns.