.catch(), no try/catch around an await. Since Node.js 15 this crashes the process with exit code 1 and 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()]. Fix it by adding try/catch to every async function and .catch() to every promise chain. Never call an async function without await unless you immediately chain .catch().
What is UnhandledPromiseRejection?
When a Promise is rejected and no handler is registered to catch that rejection,
Node.js emits the unhandledRejection process event. The rejection is "unhandled"
because no .catch() callback, no try/catch block around an
await, and no Promise.allSettled() absorb the error.
This is one of the most common async bugs in Node.js: code looks like it runs correctly but silently swallows errors — until Node.js 15 made the behavior fatal.
Node.js < 15 (warning, process continues):
(node:12345) UnhandledPromiseRejectionWarning: Error: something failed(node:12345) 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().Node.js 15+ (fatal, process crashes with exit code 1):
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: something failed] { code: 'ERR_UNHANDLED_REJECTION'}
The Node.js 15 Breaking Change
This is the single most important fact about this error:
| Node.js version | Behavior on unhandled rejection |
|---|---|
| Node.js < 15 (12, 14) | Prints UnhandledPromiseRejectionWarning to stderr. Process continues running. (Deprecated since Node.js 12.) |
| Node.js 15+ (LTS 16, 18, 20, 22, 24) | Throws the rejection as an uncaught exception. Process crashes immediately with exit code 1. Same as an unhandled synchronous throw. |
If you upgraded from Node.js 14 to 16+ and your process suddenly crashes with
UnhandledPromiseRejection, this behavioral change is why. Bugs that were
previously silently swallowed are now fatal.
Full Error Example (Terminal Output)
// This code — no await, no .catch()
getData(); // async function that rejects
// Node.js < 15 output (warning only, process continues):
(node:12345) UnhandledPromiseRejectionWarning: Error: something failed
at getData (/app/index.js:5:9)
at Object.<anonymous> (/app/index.js:10:1)
(node:12345) 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().
(node:12345) [DEP0018] DeprecationWarning: Unhandled promise rejections are
deprecated. In the future, promise rejections that are not handled will terminate
the Node.js process with a non-zero exit code.
// Node.js 15+ output (process crashes with exit code 1):
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: something failed]
at getData (/app/index.js:5:9)
at Object.<anonymous> (/app/index.js:10:1) {
code: 'ERR_UNHANDLED_REJECTION'
}
Node.js v18.12.0
Process exited with code 1
Common Causes
| Cause | Why it triggers the error |
|---|---|
Async function called without await and without .catch() |
The returned Promise is discarded. If it rejects, no handler exists. Classic "fire and forget" bug. |
Promise chain missing a .catch() handler |
A rejection in any .then() step propagates to the end of the chain. If the chain has no .catch(), the rejection is unhandled. |
Disconnected .catch() call (called on a separate statement, not chained) |
Calling p.catch(handler) on a new line after the Promise is created does not observe the original rejection if the rejection occurs in the same tick or before the catch is attached. |
Error thrown inside a .catch() handler |
If your catch handler references an undefined variable or throws a new error, that secondary rejection is itself unhandled. This is a common hidden source of the error. |
async event handler throwing without being caught |
EventEmitter does not await listeners. An async listener that throws returns a rejected Promise that is never observed by the emitter's 'error' event. |
setTimeout / setInterval with an async callback that throws |
Timer callbacks are called by the event loop; the Promise returned by an async timer callback is silently discarded. |
Array.prototype.forEach with an async callback |
forEach ignores the Promise returned by each async invocation. Any rejection inside an async forEach callback is unhandled. |
| Express 4 async route handler without wrapper | Express 4 does not await route handlers. A rejected Promise from an async handler is not forwarded to the Express error handler. |
| Async logic inside class constructors | Constructors cannot be async. Calling an async method from a constructor and not handling its returned Promise causes an unhandled rejection. |
Top-level await without a surrounding try/catch |
In ES module files (.mjs or "type": "module"), top-level await that throws without a surrounding try/catch is an unhandled rejection. |
Fix 1 – Add try/catch to async functions
The most direct fix: every async function that can throw must have a
try/catch block, or its caller must handle the rejection.
// BROKEN – rejection escapes
async function loadUser(id) {
const user = await db.findUser(id); // can reject
return user;
}
loadUser(42); // no await, no .catch() — UNHANDLED REJECTION if db.findUser rejects
// FIXED – option A: await the call and catch at call site
async function main() {
try {
const user = await loadUser(42);
console.log(user);
} catch (err) {
console.error('Failed to load user:', err);
}
}
main().catch((err) => {
console.error('Unhandled error in main:', err);
process.exit(1);
});
// FIXED – option B: add .catch() when not awaiting
loadUser(42).catch((err) => console.error('Failed to load user:', err));
.catch() to the
outermost main() call. Even if main() has try/catch
inside, an unexpected error could still propagate out. Attaching .catch() to
the top-level call is a mandatory safety net.
Fix 2 – Add .catch() to promise chains (and always chain it)
Every promise chain must terminate with a .catch(). A rejection in any
.then() step skips all subsequent .then() calls and lands in
the nearest .catch(). Critically, you must chain the .catch()
— calling it on a separate statement may not observe the rejection.
// BROKEN – no .catch()
fetch('https://api.example.com/data')
.then((res) => res.json())
.then((data) => process(data));
// If fetch() or res.json() or process() rejects — UNHANDLED REJECTION
// BROKEN – disconnected .catch() (common mistake)
const p = fetch('https://api.example.com/data').then((res) => res.json());
p.catch((err) => console.error(err)); // does NOT chain; p.catch() returns a new promise
// FIXED – .catch() chained at the end
fetch('https://api.example.com/data')
.then((res) => res.json())
.then((data) => process(data))
.catch((err) => {
console.error('API request failed:', err);
});
// FIXED – equivalent with async/await
async function loadData() {
try {
const res = await fetch('https://api.example.com/data');
const data = await res.json();
process(data);
} catch (err) {
console.error('API request failed:', err);
}
}
Fix 3 – Handle errors thrown inside .catch() handlers
A secondary unhandled rejection occurs when your .catch() handler itself
throws — for example by referencing an undefined variable or calling a function that
throws. This is one of the most insidious sources of the error: you believe you caught the
rejection, but your catch handler created a new one.
// BROKEN – catch handler throws a secondary unhandled rejection
new Promise((_, reject) => reject(new Error('original error')))
.catch((error) => {
// Bug: 'err' is not defined — ReferenceError thrown here
console.log('caught', err.message); // ReferenceError: err is not defined
// This secondary rejection is UNHANDLED
});
// FIXED – use the correct variable name
new Promise((_, reject) => reject(new Error('original error')))
.catch((error) => {
console.log('caught', error.message); // correct
});
// ALSO BROKEN – catch handler calls a function that throws
fetchData()
.catch((err) => {
notifyMonitoringService(err); // if this throws, new unhandled rejection
});
// FIXED – wrap catch handler body too
fetchData()
.catch((err) => {
try {
notifyMonitoringService(err);
} catch (monitorErr) {
console.error('Monitoring notification failed:', monitorErr);
}
});
Fix 4 – Handle rejections in async event listeners
EventEmitter is not Promise-aware. An async listener that
throws does not propagate the error to the emitter's 'error' event.
Wrap the listener body in try/catch.
const EventEmitter = require('events');
const emitter = new EventEmitter();
// BROKEN – async listener, rejection is NOT caught by the emitter's error event
emitter.on('data', async (payload) => {
const result = await processPayload(payload); // can reject
await saveResult(result); // can reject
});
emitter.on('error', (err) => console.error('emitter error:', err));
// This 'error' listener does NOT receive async listener rejections!
// FIXED – wrap async listener in try/catch
emitter.on('data', async (payload) => {
try {
const result = await processPayload(payload);
await saveResult(result);
} catch (err) {
// Handle the error directly, or emit it to the error handler:
emitter.emit('error', err);
}
});
Fix 5 – Handle async callbacks in setTimeout / setInterval
Timer callbacks are invoked by the event loop. The Promise returned by an
async timer callback is discarded — any rejection is unhandled.
// BROKEN – async callback, rejection escapes
setTimeout(async () => {
await doSomethingThatMightFail(); // UNHANDLED REJECTION if this rejects
}, 1000);
// FIXED – wrap in try/catch
setTimeout(async () => {
try {
await doSomethingThatMightFail();
} catch (err) {
console.error('Timer callback error:', err);
}
}, 1000);
// ALTERNATIVE – extract to a named async function with its own error handling
async function timerTask() {
try {
await doSomethingThatMightFail();
} catch (err) {
console.error('Timer task error:', err);
}
}
setInterval(timerTask, 5000);
Fix 6 – Replace async forEach with Promise.all + map
Using Array.prototype.forEach with an async callback is one
of the most common sources of unhandled rejections. forEach ignores the
Promise returned by each invocation. Use Promise.all with .map()
instead.
const items = [1, 2, 3];
// BROKEN – forEach discards each Promise returned by the async callback
items.forEach(async (item) => {
await processItem(item); // UNHANDLED REJECTION if processItem rejects
});
// FIXED – Promise.all + map: all rejections are captured and propagated
await Promise.all(items.map(async (item) => {
await processItem(item);
}));
// FIXED – for...of loop (sequential, easier to debug):
for (const item of items) {
try {
await processItem(item);
} catch (err) {
console.error('Failed on item', item, err);
}
}
// FIXED – parallel with independent per-item error handling:
await Promise.allSettled(items.map(async (item) => {
try {
await processItem(item);
} catch (err) {
console.error('Failed on item', item, err);
}
}));
Array.prototype.map, filter,
reduce, and other array methods also ignore returned Promises unless you wrap
them with Promise.all(). The same issue applies to .forEach()
on Set and Map iterables.
Fix 7 – Handle async errors in Express 4 route handlers
Express 4 does not catch Promise rejections from async route handlers.
Either wrap handlers with a helper, or upgrade to Express 5 which handles this natively.
// BROKEN in Express 4 – async handler, rejection not forwarded to Express error handler
app.get('/user/:id', async (req, res) => {
const user = await db.findUser(req.params.id); // rejects = UNHANDLED REJECTION
res.json(user);
});
// FIX A – asyncHandler wrapper (Express 4)
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/user/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
}));
// FIX B – express-async-errors package (Express 4)
// npm install express-async-errors
require('express-async-errors'); // patches Express to handle async errors automatically
app.get('/user/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user);
});
// FIX C – Express 5 (handles async route handlers natively)
// npm install express@5
app.get('/user/:id', async (req, res) => {
const user = await db.findUser(req.params.id); // rejections auto-forwarded to next(err)
res.json(user);
});
// In all cases, register an Express error handler:
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message });
});
Fix 8 – Avoid async logic in class constructors
Class constructors cannot be async. Calling an async method inside a
constructor and not handling the returned Promise causes an unhandled rejection.
Use a static factory method instead.
// BROKEN – async call in constructor, returned Promise discarded
class Database {
constructor(url) {
this.connect(url); // returns a Promise that is never awaited or caught
}
async connect(url) {
this.conn = await createConnection(url); // UNHANDLED REJECTION if this fails
}
}
const db = new Database('mongodb://localhost'); // bug hidden here
// FIXED – static async factory method
class Database {
constructor(conn) {
this.conn = conn;
}
static async create(url) {
const conn = await createConnection(url); // rejections propagate to caller
return new Database(conn);
}
}
const db = await Database.create('mongodb://localhost'); // caller handles rejection
Fix 9 – Use process.on('unhandledRejection') as a last resort
A global handler is useful for centralized logging and graceful shutdown, but it is not a substitute for proper per-Promise error handling. Every rejection that reaches this handler represents a bug.
// Last-resort handler — log and exit gracefully
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Promise rejection at:', promise, 'reason:', reason);
// In production: report to error tracking (Sentry, Datadog, etc.)
// Always exit — running with an unhandled rejection means unknown state
process.exit(1);
});
// DO NOT do this — silently swallowing hides real bugs:
process.on('unhandledRejection', () => {}); // WRONG
// DO NOT do this — logging without exiting leaves the process in unknown state:
process.on('unhandledRejection', (reason) => {
console.error(reason); // logs but does not exit — WRONG for Node.js 15+
});
process.on('unhandledRejection') handler,
the default throw behavior is suppressed — but only if your handler
calls process.exit() or performs another terminal action. If your handler
returns without exiting, the process continues in an unknown state. Always call
process.exit(1) after logging.
Fix 10 – Enable no-floating-promises lint rule
The TypeScript ESLint rules @typescript-eslint/no-floating-promises and
@typescript-eslint/no-misused-promises flag any Promise-returning
call that is not awaited and has no .catch(). Catching this at
lint time prevents runtime crashes.
// Install:
// npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser
// eslint.config.js (flat config):
import tseslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
{
plugins: { '@typescript-eslint': tseslint },
languageOptions: { parser: tsParser },
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error'
}
}
];
// The rules catch:
someAsyncFunction(); // ERROR: floating promise — missing await or .catch()
arr.forEach(async (item) => { // ERROR: no-misused-promises — forEach ignores returned Promise
await processItem(item);
});
// Correct:
await someAsyncFunction();
someAsyncFunction().catch(handleError);
await Promise.all(arr.map(async (item) => processItem(item)));
Controlling Behavior with --unhandled-rejections Flag
Node.js provides a command-line flag to control what happens when an unhandled rejection occurs. Knowing these modes helps during debugging and migration.
| Flag value | Behavior | When to use |
|---|---|---|
--unhandled-rejections=throw |
Default since Node.js 15. Crashes with exit code 1. | Production. Always use this. |
--unhandled-rejections=warn |
Logs a warning but does not crash. The Node.js 14 default. | Temporary — migrating a large codebase to Node.js 15+. |
--unhandled-rejections=none |
Silently ignores all unhandled rejections. | Never in production. Useful only to isolate unrelated issues during debugging. |
--unhandled-rejections=strict |
Always throws, even if a rejectionHandled event is later emitted. |
Strictest mode — use to surface late-attached handlers as bugs. |
# Run with warn mode temporarily while migrating from Node 14 to 18:
node --unhandled-rejections=warn app.js
# Force throw mode to reproduce the crash (useful on older Node.js):
node --unhandled-rejections=throw app.js
Debugging: Finding the Source
When the stack trace does not include a useful line from your own source files, use these techniques to locate the exact promise that is rejecting.
Read the stack trace — ignore node:internal/ frames
The stack trace points to where the rejected Promise was created, not where it
was called from. Look for the first frame that belongs to your own code — ignore all
node:internal/ lines.
# Force throw behavior even on older Node.js versions:
node --unhandled-rejections=throw app.js
# Show full stack trace for all process warnings (including deprecation warnings):
node --trace-warnings app.js
Identify the rejection reason and promise with a diagnostic handler
process.on('unhandledRejection', (reason, promise) => {
console.error('=== UNHANDLED REJECTION ===');
console.error('Promise:', promise);
console.error('Reason:', reason);
if (reason instanceof Error) {
console.error('Stack:', reason.stack);
}
process.exit(1);
});
Use AsyncLocalStorage to track which request caused the rejection
const { AsyncLocalStorage } = require('async_hooks');
const requestContext = new AsyncLocalStorage();
// Wrap each incoming request in a context store:
app.use((req, res, next) => {
requestContext.run({ requestId: req.id, url: req.url }, next);
});
process.on('unhandledRejection', (reason) => {
const ctx = requestContext.getStore();
console.error('Unhandled rejection in request context:', ctx, reason);
process.exit(1);
});
Debugging Checklist
- Search your codebase for
asyncfunctions called withoutawait— any of them can produce an unhandled rejection if they reject. - Search for
.then(without a matching.catch(chained at the end of the same expression. - Search for
forEach(async— replace all occurrences withPromise.all(arr.map(async. - Inspect every
.catch()handler — does it reference the correct variable name? Does any code inside it throw? - Check Express route handlers — they need an asyncHandler wrapper, the express-async-errors package, or Express 5.
- Check
EventEmitterlisteners — anyasynclistener needs its owntry/catch. - Check
setTimeout/setIntervalcallbacks — wrapasynctimer callbacks intry/catch. - Check class constructors for async method calls — convert to static factory methods.
- Add the
@typescript-eslint/no-floating-promisesand@typescript-eslint/no-misused-promiseslint rules to catch these statically. - Add
main().catch(err => { console.error(err); process.exit(1); })at every top-level entry point.
Frequently Asked Questions
What is UnhandledPromiseRejection in Node.js?
UnhandledPromiseRejection occurs when a Promise is rejected and no .catch() handler or try/catch block around an await is present to handle the rejection. In Node.js 15+ it crashes the process immediately with exit code 1 and error code ERR_UNHANDLED_REJECTION. In Node.js older than 15 it printed UnhandledPromiseRejectionWarning to stderr but the process continued, hiding the bug.
What changed in Node.js 15 — why did upgrading break my app?
Before Node.js 15, unhandled rejections emitted a deprecation warning but the process kept running. Starting with Node.js 15, the default behavior changed to throw — the rejection is re-thrown as an uncaught exception, crashing the process with exit code 1. This exposed pre-existing bugs that were previously silently ignored. The fix is to add the missing error handlers, not to downgrade Node.js.
Why do I still get the error even though I have a .catch() handler?
Two common reasons: (1) Disconnected .catch() — you called .catch() as a separate statement instead of chaining it on the same expression. Chain it directly: promise.then(...).catch(...). (2) Secondary rejection inside the catch handler — if your .catch() callback throws a new error (e.g., referencing an undefined variable like err instead of error), that new rejection is itself unhandled. Inspect your catch callbacks carefully.
Why does async forEach cause UnhandledPromiseRejection?
Array.prototype.forEach does not do anything with the return value of its callback. When an async callback is used, forEach receives a Promise and discards it. If that Promise rejects, the rejection is never observed. Replace items.forEach(async cb) with await Promise.all(items.map(async cb)) or a for...of loop with try/catch.
How do I use process.on('unhandledRejection') correctly?
Use it only as a last-resort safety net for logging and graceful shutdown — not as a replacement for per-Promise error handling. Always call process.exit(1) after logging. Never use an empty handler (() => {}) to silence the error — it hides real bugs. In Node.js 15+, if your handler returns without calling process.exit(), the process may continue in an inconsistent state.
How do I use the --unhandled-rejections flag?
Run node --unhandled-rejections=warn app.js to temporarily restore the Node.js 14 behavior (warning, no crash) while migrating a large codebase. Use --unhandled-rejections=throw (the default) for production. Use --unhandled-rejections=none only for temporary isolated debugging — never in production. Use --unhandled-rejections=strict to throw even when a handler is attached late.
What is PromiseRejectionHandledWarning?
PromiseRejectionHandledWarning is emitted when a .catch() handler is attached to a Promise after the current event loop tick — too late to prevent the unhandledRejection event. This happens when you create a Promise in one tick and attach the handler asynchronously later. Fix by attaching .catch() synchronously in the same expression that creates the Promise.
Related Errors
TypeError: Cannot read properties of undefined— often the underlying rejection reason inside an async function; frequently appears as thereasonin theunhandledRejectioneventECONNREFUSED: connect ECONNREFUSED— a common rejection reason from database or HTTP client calls inside async functions; if not caught, causes UnhandledPromiseRejectionETIMEDOUT— another frequent async rejection reason from network calls; must be caught in async functions or .catch() handlersERR_REQUIRE_ESM— can cause an unexpected rejection when requiring an ES module from a CommonJS context inside an async functionERR_UNHANDLED_REJECTION— thecodeproperty set on the thrown rejection object in Node.js 15+; found in the error output as{ code: 'ERR_UNHANDLED_REJECTION' }UnhandledPromiseRejectionWarning— the older form of this error emitted as a process warning in Node.js < 15; same root cause, different behaviorPromiseRejectionHandledWarning— emitted when a.catch()is attached to a Promise after the current tick (too late to prevent the unhandledRejection event)uncaughtException— the synchronous equivalent: a thrown error with no surroundingtry/catch; handled viaprocess.on('uncaughtException')