How to Debug Unhandled Promise Rejections in Node.js
How do I debug unhandled promise rejections in Node.js?
TL;DR
- Bottom line: An unhandled promise rejection occurs when a Promise is rejected but no
.catch()ortry/catchhandles the error. Since Node.js 15+, unhandled rejections crash the process by default (--unhandled-rejections=throw). The top causes: (1) missing.catch()on promise chains, (2)asyncfunctions withouttry/catch, (3) forgottenawait(floating promises), (4) errors in.then()callbacks, (5)Promise.all()without error handling. - Key tool/command:
node --unhandled-rejections=strict app.jsto crash immediately on unhandled rejections during development. Use ESLint rule@typescript-eslint/no-floating-promisesto catch them at lint time. - Watch out for:
asyncfunctions in event handlers,setTimeoutcallbacks, andArray.forEach—awaitdoesn't work inforEach, so rejections silently escape. - Works with: Node.js 15+ (crashes by default), Node.js 12-14 (warning only). Applies to all frameworks: Express 4/5, Fastify, NestJS, Koa.
Constraints
- Node.js 15+ crashes by default on unhandled rejections — code that "worked" on Node.js 14 will crash after upgrade. [src1]
process.on('unhandledRejection')is a diagnostic safety net, not a permanent fix — swallowing errors hides bugs and leaves the process in an inconsistent state. [src1, src3]- Never use
asynccallbacks withArray.forEach— rejections silently escape becauseforEachdoes notawait. Usefor...oforPromise.all(arr.map(...)). [src5] - Express 4 does not catch async route handler rejections — use
express-async-errors, a manual wrapper, or upgrade to Express 5 (which handles async natively since v5.0.0, released 2025-01-11). [src7, src8] - In production, always exit after an unhandled rejection — continuing risks corrupted state, leaked connections, and silent data loss. [src1, src3]
- Jest/Mocha test runners swallow unhandled rejections as warnings, not test failures — use
expect(...).rejects.toThrow()explicitly. [src3]
Quick Reference
| # | Cause | Likelihood | Signature | Fix |
|---|---|---|---|---|
| 1 | Missing .catch() on promise chain |
~25% of cases | UnhandledPromiseRejectionWarning with stack trace of .then() |
Add .catch() to every promise chain [src1, src2] |
| 2 | async function without try/catch |
~22% of cases | Crash at await line; no handler above |
Wrap await calls in try/catch blocks [src2, src3]
|
| 3 | Floating promise (missing await) |
~18% of cases | Promise fires but rejection has no handler | Add await or .catch(); enable no-floating-promises
lint rule [src4] |
| 4 | Error thrown inside .then() callback |
~10% of cases | Stack trace points to .then() handler code |
Chain .catch() after the .then() [src2] |
| 5 | Promise.all() without error handling |
~8% of cases | One rejection crashes; other results lost | Use Promise.allSettled() or wrap with try/catch [src6]
|
| 6 | Express async route without error wrapper | ~7% of cases | Express hangs, no error response sent | Use express-async-errors (Express 4) or upgrade to Express 5 [src7, src8] |
| 7 | Event handler with async callback | ~4% of cases | emitter.on('event', async fn) — rejection escapes |
Wrap async callback body in try/catch [src3,
src5]
|
| 8 | async in forEach/map without await |
~3% of cases | Iterations start but rejections aren't caught | Use for...of loop with await or
Promise.all(arr.map(...)) [src5]
|
| 9 | Conditional .catch() (missing in some code paths) |
~2% of cases | Only fails in certain conditions | Ensure .catch() on all branches [src3]
|
| 10 | Rejected promise in module top-level | ~1% of cases | Crash during import/require initialization |
Wrap top-level async in IIFE with error handling [src2] |
Decision Tree
START
├── Is the error "UnhandledPromiseRejectionWarning" or immediate crash?
│ ├── WARNING (Node 14 or earlier) → Add --unhandled-rejections=strict to catch early [src1]
│ └── CRASH (Node 15+) → Default behavior, read the stack trace ↓
├── Does the stack trace show a specific file and line?
│ ├── YES → Go to that line
│ │ ├── Is there an `await` without `try/catch`? → Add try/catch [src2, src3]
│ │ ├── Is there a `.then()` without `.catch()`? → Add .catch() [src2]
│ │ ├── Is there a function call returning a Promise without await? → Floating promise — add await [src4]
│ │ └── Is it inside a callback (event handler, setTimeout, forEach)? → Wrap in try/catch [src5]
│ └── NO (stack trace is unhelpful) ↓
├── Enable async stack traces: node --async-stack-traces app.js
│ └── Still unclear → Add process.on('unhandledRejection') with full logging [src1]
├── Is it in an Express route?
│ ├── YES, Express 4 → Use express-async-errors or wrap route handler [src7]
│ ├── YES, Express 5 → Async errors are caught automatically — check error middleware [src8]
│ └── NO ↓
├── Is it in a loop processing multiple items?
│ ├── YES → Using forEach? → Switch to for...of with await [src5]
│ │ └── Using Promise.all? → Switch to Promise.allSettled or add try/catch [src6]
│ └── NO ↓
└── Add global handler as safety net: process.on('unhandledRejection') [src1]
Step-by-Step Guide
1. Understand the Node.js version behavior
The way Node.js handles unhandled rejections changed significantly across versions. [src1]
Node.js 10-14: Warning only (DEP0018 deprecation warning), process continues
Node.js 15+: Throws, process crashes (--unhandled-rejections=throw is default)
# Check your Node.js version
node -v
# Force strict mode in development (crash immediately)
node --unhandled-rejections=strict app.js
# Modes: throw (default 15+), warn (default <15), strict, none
node --unhandled-rejections=throw app.js
Verify: Run your app with --unhandled-rejections=strict — any unhandled
rejections will crash immediately with a stack trace.
2. Add a global handler for diagnosis
While you find and fix the root cause, add a global handler to log full details. [src1]
// Add at the top of your entry file (app.js / index.js)
process.on('unhandledRejection', (reason, promise) => {
console.error('UNHANDLED REJECTION at:', promise);
console.error('Reason:', reason);
console.error('Stack:', reason?.stack || 'No stack trace');
// In production: log to error tracker, then exit
// process.exit(1);
});
// Also catch uncaught exceptions (synchronous)
process.on('uncaughtException', (error) => {
console.error('UNCAUGHT EXCEPTION:', error);
process.exit(1);
});
Verify: Run your app and trigger the failing code path — you should see the full error details logged.
3. Fix missing try/catch in async functions
The most common pattern: async function without error handling. [src2, src3]
// ✅ CORRECT — error caught and handled
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`Failed to fetch user ${userId}:`, error.message);
throw error;
}
}
Verify: The error is caught and logged, not crashing the process.
4. Find and fix floating promises
A "floating promise" is a promise that's not awaited or .catch()-ed. [src4]
// ❌ WRONG — floating promise
function handleRequest(req, res) {
saveToDatabase(req.body); // Returns promise, nobody handles it!
res.json({ ok: true });
}
// ✅ CORRECT — await the promise
async function handleRequest(req, res) {
try {
await saveToDatabase(req.body);
res.json({ ok: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
}
Verify: Enable @typescript-eslint/no-floating-promises ESLint rule to catch
these at build time.
5. Fix Promise.all() error handling
Promise.all() rejects as soon as one promise rejects, losing other results. [src6]
// ✅ CORRECT — Promise.allSettled handles partial failures
const results = await Promise.allSettled([
fetchUser(1), fetchUser(2), fetchUser(3),
]);
const successes = results.filter(r => r.status === 'fulfilled').map(r => r.value);
const failures = results.filter(r => r.status === 'rejected').map(r => r.reason);
if (failures.length) console.error(`${failures.length} fetches failed:`, failures);
Verify: All operations complete; failures are logged, successes are used.
6. Fix Express async routes
Express 4 doesn't natively catch async route handler rejections. Express 5 (released 2025-01-11) fixes this. [src7, src8]
// ✅ FIX 1 — Use express-async-errors (Express 4, simplest)
require('express-async-errors');
// ✅ FIX 2 — Manual wrapper (Express 4)
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.getUser(req.params.id);
res.json(user);
}));
// ✅ FIX 3 — Express 5 (handles async natively, released 2025-01-11)
// No wrapper needed — just ensure error middleware has 4 params
// Error middleware (required — must have 4 params)
app.use((err, req, res, next) => {
console.error('Error:', err.message);
res.status(err.status || 500).json({ error: err.message });
});
Verify: Trigger an error in the route — Express responds with the error middleware, no crash.
7. Fix async in loops
Array.forEach doesn't await async callbacks — rejections escape silently. [src5]
// ❌ WRONG — forEach doesn't handle async rejections
items.forEach(async (item) => {
await processItem(item); // Rejection has no handler!
});
// ✅ CORRECT — for...of with await (sequential)
for (const item of items) {
await processItem(item);
}
// ✅ CORRECT — Promise.all with map (parallel)
await Promise.all(items.map(item => processItem(item)));
Verify: All items are processed; errors are caught, not silently dropped.
Code Examples
Global error handler with graceful shutdown
// Input: App crashes randomly from various unhandled rejections
// Output: Comprehensive global handler with logging and graceful shutdown
let isShuttingDown = false;
process.on('unhandledRejection', (reason, promise) => {
console.error('=== UNHANDLED PROMISE REJECTION ===');
console.error('Reason:', reason);
console.error('Stack:', reason?.stack || 'No stack');
// Sentry.captureException(reason);
if (!isShuttingDown) {
isShuttingDown = true;
gracefulShutdown('unhandledRejection', 1);
}
});
process.on('uncaughtException', (error, origin) => {
console.error(`=== UNCAUGHT EXCEPTION (${origin}) ===`);
console.error(error);
if (!isShuttingDown) {
isShuttingDown = true;
gracefulShutdown('uncaughtException', 1);
}
});
async function gracefulShutdown(signal, exitCode = 0) {
console.log(`${signal} received — shutting down gracefully`);
const timeout = setTimeout(() => process.exit(1), 10000);
try {
if (global.server) await new Promise(r => global.server.close(r));
if (global.dbPool) await global.dbPool.end();
} finally {
clearTimeout(timeout);
process.exit(exitCode);
}
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Safe async utility functions
// Input: Need reusable patterns for safe async operations
// Output: Utility functions that prevent unhandled rejections
// Go-style error handling: [error, result] tuple
async function safely(promise) {
try {
const result = await promise;
return [null, result];
} catch (error) {
return [error, null];
}
}
// Usage
const [err, user] = await safely(db.getUser(123));
if (err) {
console.error('Failed:', err.message);
return res.status(500).json({ error: 'Database error' });
}
res.json(user);
// Batch operations with individual error handling
async function safelyAll(promises, options = {}) {
const results = await Promise.allSettled(promises);
return results.map((r, i) => {
if (r.status === 'rejected') {
if (options.onError) options.onError(r.reason, i);
return { ok: false, error: r.reason };
}
return { ok: true, value: r.value };
});
}
const results = await safelyAll(
userIds.map(id => db.getUser(id)),
{ onError: (err, i) => console.error(`User ${userIds[i]} failed:`, err) }
);
const users = results.filter(r => r.ok).map(r => r.value);
Express app with comprehensive error handling
// Input: Express app with async routes that crash on errors
// Output: Production-ready error handling setup
const express = require('express');
require('express-async-errors');
const app = express();
app.use(express.json());
// Routes — async errors auto-forwarded to error middleware
app.get('/users/:id', async (req, res) => {
const user = await db.getUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Centralized error middleware (must have 4 params)
app.use((err, req, res, next) => {
const status = err.status || 500;
const message = status === 500 ? 'Internal server error' : err.message;
console.error(`[${req.method} ${req.path}] ${status}:`, err.message);
if (status === 500) console.error(err.stack);
res.status(status).json({
error: message,
...(process.env.NODE_ENV !== 'production' && { stack: err.stack }),
});
});
app.listen(3000);
Anti-Patterns
Wrong: Async function without try/catch
// ❌ BAD — rejection propagates as unhandled [src2, src3]
app.get('/data', async (req, res) => {
const data = await fetchExternalAPI(); // If API is down → crash
res.json(data);
});
Correct: Wrap in try/catch
// ✅ GOOD — error caught and handled [src2, src3]
app.get('/data', async (req, res, next) => {
try {
const data = await fetchExternalAPI();
res.json(data);
} catch (error) {
next(error);
}
});
Wrong: forEach with async callbacks
// ❌ BAD — forEach doesn't handle async rejections [src5]
async function processAll(items) {
items.forEach(async (item) => {
await sendEmail(item.email); // If one fails → unhandled
});
console.log('Done'); // Logs immediately, before emails send!
}
Correct: for...of or Promise.all
// ✅ GOOD — proper sequential processing [src5]
async function processAll(items) {
for (const item of items) {
try {
await sendEmail(item.email);
} catch (err) {
console.error(`Failed to email ${item.email}:`, err.message);
}
}
console.log('Done');
}
Wrong: Fire-and-forget without .catch()
// ❌ BAD — floating promise, rejection unhandled [src4]
function logUserAction(userId, action) {
db.insertLog({ userId, action, timestamp: new Date() });
// Returns promise but nobody handles it!
}
Correct: Always handle the promise
// ✅ GOOD — explicit .catch() for fire-and-forget [src4]
function logUserAction(userId, action) {
db.insertLog({ userId, action, timestamp: new Date() })
.catch(err => console.error('Log insert failed:', err.message));
}
Common Pitfalls
asyncinforEachdoesn't work:Array.forEachdoesn't handle async callbacks — rejections become unhandled. Usefor...ofwithawaitorPromise.all()with.map(). [src5]- Express 4 doesn't catch async errors: Async route handlers that throw or reject bypass
Express error middleware entirely. Use
express-async-errorsor a manual wrapper. Express 5 (released 2025-01-11) fixes this natively — it automatically forwards rejected promises to error middleware. [src7, src8] process.on('unhandledRejection')is a safety net, not a fix: Using the global handler to silently swallow errors hides bugs. Log, alert, and fix the root cause. [src1, src3]- Conditional promise chains: If a
.catch()is only on one branch of anif/else, the other branch's rejection is unhandled. Always catch on all paths. [src3] setTimeout/setIntervalwith async callbacks: Timer callbacks run outside the promise chain. Rejections insidesetTimeout(async () => {...})are unhandled unless wrapped intry/catch. [src5]- Node.js 15+ crash vs 14 warning: Code that "worked" on Node.js 14 (with warnings) will crash on 15+. Upgrading Node.js versions may expose hidden bugs. [src1]
Error.isError()for robust checking (Node.js 24+): UseError.isError(reason)instead ofreason instanceof Errorin the global handler — it works across realms (iframes, vm contexts). [src2]
Diagnostic Commands
# Run with strict mode — crash immediately on unhandled rejection
node --unhandled-rejections=strict app.js
# Run with async stack traces (better debugging)
node --async-stack-traces app.js
# Both flags together
node --unhandled-rejections=strict --async-stack-traces app.js
# Check which Node.js mode is default for your version
node -e "console.log(process.version, process.execArgv)"
# Find floating promises in TypeScript projects
npx eslint . --rule '{"@typescript-eslint/no-floating-promises": "error"}'
# Find forEach with async
grep -rn "forEach(async" --include="*.js" --include="*.ts" src/
# Find all .catch() patterns
grep -rn "\.catch(" --include="*.js" --include="*.ts" src/
# Check Express version (5+ handles async natively)
node -e "console.log(require('express/package.json').version)"
# Test global handler
node -e "
process.on('unhandledRejection', r => console.log('CAUGHT:', r));
Promise.reject(new Error('test'));
"
Version History & Compatibility
| Version | Behavior | Key Changes |
|---|---|---|
| Node.js 26 (Current) | Crash (default) | Same default --unhandled-rejections=throw behavior; no DEP0018 changes; documented at v26.1.0 [src1, src2] |
| Node.js 25 | Crash (default) | Removes multipleResolves event; portable compile cache; Web Storage default [src1] |
| Node.js 24 | Crash (default) | V8 13.6; Error.isError() for cross-realm checks;
using/await using for resource cleanup [src1, src2] |
| Node.js 23 | Crash (default) | 15-20% faster bootstrap; require(esm) unflagged [src1] |
| Node.js 22 LTS | Crash (default) | error.cause in promise rejections; improved diagnostics [src1, src2] |
| Node.js 20 LTS | Crash (default) | Promise.withResolvers() for cleaner promise patterns [src1] |
| Node.js 18 LTS (EOL) | Crash (default) | Built-in fetch() (experimental); async errors catchable [src1] |
| Node.js 16 (EOL) | Crash (default) | --unhandled-rejections=throw became default [src1] |
| Node.js 15 (EOL) | Crash (default) | First version to crash by default on unhandled rejections [src1] |
| Node.js 14 (EOL) | Warning only | UnhandledPromiseRejectionWarning; deprecation DEP0018 [src1] |
| Node.js 12 (EOL) | Warning only | --unhandled-rejections flag introduced [src1] |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Process crashes with "unhandled rejection" | Error is a caught exception (sync throw) | Standard try/catch debugging |
UnhandledPromiseRejectionWarning in logs |
Error is ECONNREFUSED (connection issue) |
Check service connectivity first |
| Express route hangs without responding | Error is in a callback-based API (not Promise) | Check callback error parameter |
| Upgrading Node.js version causes new crashes | Error is logged but handled correctly | Verify handler is working as expected |
| Jest/Mocha shows warning but test passes | Error is memory-related (heap OOM) | Node.js memory leak debugging |
Important Caveats
- Rejections are not the same as thrown errors: A rejected promise only becomes an
"unhandled rejection" after the microtask queue processes. Attaching
.catch()even slightly later can prevent the error — but this is fragile and not recommended. process.on('unhandledRejection')prevents crash but hides bugs: If you handle the event and don't exit, the process continues in a potentially inconsistent state. In production, log the error and restart.- Express 5 changes everything: Express 5 (stable release 2025-01-11) natively handles
async route handler rejections, forwarding them to error middleware automatically. This eliminates the
need for
express-async-errorsor manual wrappers. Express 5 requires Node.js 18+. [src8] Promise.allSettled()vsPromise.all():Promise.all()fails fast — the first rejection rejects the whole batch.Promise.allSettled()waits for all promises and reports individual outcomes. Choose based on whether partial results are useful.- Jest/Mocha test frameworks: Test runners have their own unhandled rejection handling.
An unhandled rejection in a test may show as a warning rather than a test failure — use
expect(...).rejects.toThrow()explicitly. - Node.js 24+
using/await using(explicit resource management): Theawait usingsyntax (V8 13.6) provides deterministic cleanup for resources like database connections and file handles, reducing the chance of rejections from leaked resources. [src2] - Node.js 25 removed
multipleResolvesevent: Theprocess.on('multipleResolves')event was removed in Node.js 25. If you relied on it for diagnostics, switch to theunhandledRejectionevent andError.isError()checks. [src1]