Fix: Node.js Crashing with UnhandledPromiseRejection (--unhandled-rejections)
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix Node.js UnhandledPromiseRejectionWarning and process crashes — why unhandled promise rejections crash Node.js 15+, how to add global handlers, find the source of the rejection, and fix async error handling.
The Error
Node.js exits with an unhandled promise rejection error:
node:internal/process/promises:289
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 127.0.0.1:5432]Or in older Node.js versions — a warning instead of a crash:
(node:1234) UnhandledPromiseRejectionWarning: Error: connect ECONNREFUSED
(node:1234) 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().Why This Happens
In Node.js 15+, an unhandled promise rejection crashes the process with exit code 1. In older versions it was a warning. The rejection is “unhandled” when:
- An
asyncfunction throws but is notawaited — calling an async function withoutawaitmeans its rejection has no.catch()handler. - A
.then()chain has no.catch()— any rejection in the chain propagates until it reaches a.catch(). Without one, it’s unhandled. Promise.all()orPromise.allSettled()has a rejecting promise — if thePromise.all()call itself is notawaited or not followed by.catch(), the rejection is unhandled.- Event emitter callbacks that are async —
EventEmitterdoesn’t understand promises. If an async listener throws, the rejection is unhandled. - Express route handlers not calling
next(err)— unhandled async errors in Express 4 don’t automatically reach the error handler.
The deeper reason is that Promise is not exception-aware in the synchronous sense. A throw inside an async function becomes Promise.reject(error). If nobody attaches a .catch or awaits the promise before the next microtask, the V8 engine has no idea anyone wanted to know about the error. The runtime fires the unhandledRejection event one microtask later, by which time the calling code has long since moved on. This is why the stack trace usually does not include the line that “forgot” to handle the error — the call stack at the point of detection is empty.
The third subtlety is the difference between a rejection that is detected late and one that never gets handled. If you write const p = doSomething(); and later, after a setTimeout(..., 100), you write p.catch(handle), Node may still report an unhandled rejection for those 100 ms before the handler attaches. This is the source of the “phantom” rejections that appear in well-tested code. They are not bugs in V8 — they are real handler-attachment races. Starting in Node 16, the unhandledRejection and rejectionHandled events fire in a pair, and you can listen for rejectionHandled to clear false alarms.
Version History That Changes the Failure Mode
The default behavior of unhandled rejections has changed five times in Node.js history. The exact version you run determines whether the process keeps going, logs a warning, or hard-crashes.
Node.js 6 (April 2016). The unhandledRejection event first appeared. By default, nothing happened. Your code crashed silently or appeared to hang.
Node.js 10 (April 2018). The --unhandled-rejections flag was added with values none and warn. The default remained “warn and continue.”
Node.js 12 (April 2019). Added --unhandled-rejections=strict, which crashed the process. Opt-in, not default.
Node.js 14 (April 2020). Deprecation warning added: “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.” This is the warning many production applications were ignoring.
Node.js 15 (October 2020) — the breaking change. The default flipped to --unhandled-rejections=throw. Code that ran for years on Node 14 started exit-coding-1 on Node 15 the same day. This is the single most-cited reason developers post about this error today. The change propagated forward to Node 16, 18, 20, and 22.
Node.js 16 LTS (April 2021). Inherited the Node 15 default. Added the paired rejectionHandled event, which lets you cancel a false “unhandled” detection if you attach a .catch slightly late.
Node.js 18 LTS (April 2022). Native fetch() arrived. Note that fetch itself returns a promise, and forgetting to .catch it on Node 18 follows the same crash semantics as any other promise. Many “Node 18 broke our app” reports trace to new fetch calls without error handling.
Node.js 20 LTS (April 2023). Default behavior unchanged. The --unhandled-rejections=warn escape hatch still works but is officially discouraged. Node 20 also added stable Worker thread support, where unhandled rejections inside a worker terminate the worker but not the parent — unless the parent itself does not handle the worker’s error event, in which case the parent crashes too.
Node.js 22 LTS (April 2024). Added improved diagnostics — --report-uncaught-exception now also fires for unhandled rejections, generating a JSON crash dump alongside the stderr trace.
The escape hatch. Across Node 15+, you can still set node --unhandled-rejections=warn server.js to get Node 14 behavior. This is a useful unblock during an upgrade but is genuinely a temporary measure. Worker threads and built-in modules increasingly assume rejections crash, and the warn-only mode introduces latent inconsistencies between main and worker code.
Fix 1: Add try/catch to Every async Function
// Wrong — async function called without await or .catch()
async function loadData() {
const data = await db.query('SELECT * FROM users'); // Can throw
return data;
}
// Called but not awaited — rejection is unhandled
loadData(); // Fire and forget — any error is swallowed or unhandled
// Correct — always await or handle the rejection
async function main() {
try {
const data = await loadData(); // Awaited — any rejection is caught here
console.log(data);
} catch (err) {
console.error('Failed to load data:', err);
process.exit(1); // Or handle gracefully
}
}
main(); // Top-level call — still fire-and-forget but errors are caught insideWrap all top-level async code:
// Pattern for Node.js scripts
async function main() {
// All your async code here — errors caught and handled
const db = await connectToDatabase();
await runMigrations(db);
await startServer(db);
}
main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});Fix 2: Fix Promise Chains Without .catch()
// Wrong — no .catch() on the chain
fetch('https://api.example.com/data')
.then(res => res.json())
.then(data => processData(data));
// If fetch fails or processData throws, rejection is unhandled
// Correct — add .catch()
fetch('https://api.example.com/data')
.then(res => res.json())
.then(data => processData(data))
.catch(err => {
console.error('Request failed:', err);
});
// Or use async/await for cleaner error handling
async function fetchData() {
try {
const res = await fetch('https://api.example.com/data');
const data = await res.json();
return processData(data);
} catch (err) {
console.error('Request failed:', err);
throw err; // Re-throw if caller needs to handle it
}
}Fix 3: Fix Async Event Listeners
EventEmitter does not understand promises. If an async listener rejects, the error is unhandled:
const EventEmitter = require('events');
const emitter = new EventEmitter();
// Wrong — async listener rejection is unhandled
emitter.on('data', async (payload) => {
await processPayload(payload); // If this throws, nobody catches it
});
// Correct — wrap in try/catch
emitter.on('data', async (payload) => {
try {
await processPayload(payload);
} catch (err) {
console.error('Error processing payload:', err);
emitter.emit('error', err); // Route to error handler
}
});
// For EventEmitter errors — always add an error listener
emitter.on('error', (err) => {
console.error('Emitter error:', err);
});For streams and other Node.js built-in EventEmitters:
const { pipeline } = require('stream/promises');
// Use the promise-based pipeline to handle stream errors
async function processStream(readable, writable) {
try {
await pipeline(readable, transform, writable);
} catch (err) {
console.error('Stream pipeline failed:', err);
}
}Fix 4: Fix Express Async Route Handlers
Express 4 does not automatically catch async errors — you must either call next(err) or use a wrapper:
// Wrong — async error not passed to Express error handler
app.get('/users', async (req, res) => {
const users = await db.getUsers(); // If this throws, Express doesn't catch it
res.json(users);
});
// Fix A — wrap manually
app.get('/users', async (req, res, next) => {
try {
const users = await db.getUsers();
res.json(users);
} catch (err) {
next(err); // Passes to Express error handling middleware
}
});
// Fix B — use a wrapper function
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/users', asyncHandler(async (req, res) => {
const users = await db.getUsers();
res.json(users);
}));
// Fix C — Express 5 (handles async errors automatically)
// npm install express@5
app.get('/users', async (req, res) => {
const users = await db.getUsers(); // Express 5 catches this automatically
res.json(users);
});Express error handling middleware:
// Must be defined after all routes
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production' ? 'Internal Server Error' : err.message,
});
});Fix 5: Add a Global Unhandled Rejection Handler
As a safety net — catch any rejections that slip through:
// Catch unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise);
console.error('Reason:', reason);
// In production — log to error tracking service
// Sentry.captureException(reason);
// Graceful shutdown
process.exit(1); // Node.js 15+ exits anyway, but be explicit
});
// Catch synchronous uncaught exceptions
process.on('uncaughtException', (err, origin) => {
console.error('Uncaught Exception:', err);
console.error('Origin:', origin);
// Always exit after uncaughtException — the process state is unreliable
process.exit(1);
});
// Graceful shutdown on SIGTERM (e.g., from Docker or Kubernetes)
process.on('SIGTERM', async () => {
console.log('SIGTERM received — shutting down gracefully');
await server.close();
await db.disconnect();
process.exit(0);
});Warning: Do not use
unhandledRejectionto swallow errors and continue running. After an unhandled rejection, the process may be in an inconsistent state. Log the error, then either exit or restart via a process manager (PM2, systemd).
Fix 6: Find the Source of the Unhandled Rejection
The error message often doesn’t show which line of your code caused the rejection. Use these techniques to find it:
Enable long stack traces:
# Node.js built-in — shows async stack traces
node --stack-trace-limit=50 server.js
# Or with the --enable-source-maps flag for TypeScript
node --enable-source-maps server.jsUse the --trace-warnings flag:
node --trace-warnings server.js
# Shows the full stack trace for every warning including UnhandledPromiseRejectionIntercept rejections before they become unhandled:
// Monkey-patch Promise to track unhandled rejections
const originalPromise = global.Promise;
global.Promise = class TrackedPromise extends originalPromise {
constructor(executor) {
super(executor);
// Add a no-op .catch to prevent "unhandled" but log when it fires
this.catch((err) => {
console.trace('Promise rejected without handler:', err);
});
}
};Or use the --unhandled-rejections flag to control behavior:
# Node.js 15+ options for --unhandled-rejections:
# 'throw' (default in Node 15+) — throws an exception, crashes the process
# 'warn' (Node 14 default) — logs a warning, process continues
# 'none' — silently ignores (never use in production)
# 'warn-with-error-code' — warns and sets exit code
node --unhandled-rejections=warn server.js # Temporarily use Node 14 behaviorFix 7: Fix Timer and setInterval Async Handlers
// Wrong — async callback in setInterval is not awaited
setInterval(async () => {
await syncData(); // If this throws, the rejection is unhandled
}, 60_000);
// Correct — wrap in try/catch
setInterval(async () => {
try {
await syncData();
} catch (err) {
console.error('Sync failed:', err);
// Do NOT rethrow — setInterval will continue on the next tick
}
}, 60_000);
// Better — use a self-scheduling async function for control over timing
async function scheduledSync() {
while (true) {
try {
await syncData();
} catch (err) {
console.error('Sync failed:', err);
}
await new Promise(resolve => setTimeout(resolve, 60_000));
}
}
scheduledSync().catch(err => {
console.error('Scheduler crashed:', err);
process.exit(1);
});Still Not Working?
Check third-party libraries. Some older libraries return unhandled promises internally. Check the library’s issue tracker or upgrade to a newer version.
Use Node.js --inspect with Chrome DevTools to pause on unhandled rejections:
node --inspect-brk server.js
# Open chrome://inspect in Chrome
# DevTools → Sources → Event Listener Breakpoints → Promise → Unhandled RejectionUse why-is-node-running to find what’s keeping the process alive after an error:
npm install -g why-is-node-running
# Add to your entry point:
const why = require('why-is-node-running');
setTimeout(why, 5000); // After 5s, print what's keeping Node aliveCheck Worker thread error handling. On Node 16+, unhandled rejections inside a worker_threads.Worker only terminate the worker — not the parent. But if the parent does not subscribe to the worker’s 'error' event, the error bubbles to the parent’s unhandledRejection handler and crashes the whole process. Add worker.on('error', err => { ... }) on every worker you spawn, even if you do not plan to handle the error in a meaningful way. The act of subscribing prevents accidental parent-crash escalation.
Listen for rejectionHandled to filter race-condition false positives. If you attach a .catch shortly after a promise rejects, Node may fire unhandledRejection before your handler attaches and rejectionHandled after. Tracking both lets your monitoring distinguish real bugs from microtask races:
const pending = new Map();
process.on('unhandledRejection', (reason, promise) => {
pending.set(promise, { reason, time: Date.now() });
});
process.on('rejectionHandled', (promise) => {
pending.delete(promise);
});
setInterval(() => {
for (const [promise, info] of pending) {
if (Date.now() - info.time > 5000) {
// Genuinely unhandled for 5+ seconds — log to Sentry, then drop
pending.delete(promise);
}
}
}, 5000);Use --unhandled-rejections=warn only as a temporary unblock. If you have just upgraded from Node 14 to Node 18/20 and discovered a swarm of rejections, set node --unhandled-rejections=warn in your start command, fix the rejections one by one, and remove the flag. Leaving the flag in place permanently is technical debt that will eventually catch you when a Worker thread or native module assumes the default crash behavior.
For related Node.js and async issues, see Fix: JavaScript Unhandled Promise Rejection, Fix: Python asyncio Runtime Error No Running Event Loop, Fix: Node Uncaught Exception, and Fix: Express Async Error.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
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.
Fix: Node.js UnhandledPromiseRejection and uncaughtException — Crashing Server
How to handle Node.js uncaughtException and unhandledRejection events — graceful shutdown, error logging, async error boundaries, and keeping servers alive safely.