.catch() or try/catch handles 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) async functions without
try/catch, (3) forgotten await (floating promises), (4) errors in
.then() callbacks, (5) Promise.all() without error handling.node --unhandled-rejections=strict app.js to crash
immediately on unhandled rejections during development. Use ESLint rule
@typescript-eslint/no-floating-promises to catch them at lint time.async functions in event handlers, setTimeout
callbacks, and Array.forEach — await doesn't work in forEach, so
rejections silently escape.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]async callbacks with Array.forEach — rejections silently escape
because forEach does not await. Use for...of or
Promise.all(arr.map(...)). [src5]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]expect(...).rejects.toThrow() explicitly. [src3]| # | 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] |
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]
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.
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.
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.
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.
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.
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.
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.
// 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'));
// 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);
// 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);
// ❌ 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);
});
// ✅ 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);
}
});
// ❌ 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!
}
// ✅ 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');
}
// ❌ BAD — floating promise, rejection unhandled [src4]
function logUserAction(userId, action) {
db.insertLog({ userId, action, timestamp: new Date() });
// Returns promise but nobody handles it!
}
// ✅ 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));
}
async in forEach doesn't work: Array.forEach
doesn't handle async callbacks — rejections become unhandled. Use for...of with
await or Promise.all() with .map(). [src5]express-async-errors or 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].catch() is only on one branch of an
if/else, the other branch's rejection is unhandled. Always catch on all paths. [src3]setTimeout / setInterval with async callbacks: Timer
callbacks run outside the promise chain. Rejections inside setTimeout(async () => {...})
are unhandled unless wrapped in try/catch. [src5]Error.isError() for robust checking (Node.js 24+): Use
Error.isError(reason) instead of reason instanceof Error in the global handler
— it works across realms (iframes, vm contexts). [src2]# 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 | Behavior | Key Changes |
|---|---|---|
| Node.js 25 (Current) | 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] |
| 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 |
.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-async-errors or manual wrappers. Express 5 requires Node.js 18+. [src8]Promise.allSettled() vs Promise.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.expect(...).rejects.toThrow() explicitly.using/await using (explicit resource management):
The await using syntax (V8 13.6) provides deterministic cleanup for resources like database
connections and file handles, reducing the chance of rejections from leaked resources. [src2]multipleResolves event: The
process.on('multipleResolves') event was removed in Node.js 25. If you relied on it for
diagnostics, switch to the unhandledRejection event and Error.isError()
checks. [src1]