Unhandled Promise Rejections
An unhandled promise rejection is a rejected Promise that has no .catch() handler — either because the code that created it forgot to attach one, or because an async function's rejection was never awaited inside a try/catch. Before Node 15 this printed a deprecation warning. From Node 15 onwards it crashes the process with exit code 1, just like an uncaught synchronous exception. process.on('unhandledRejection') lets you intercept these, log them, and shut down cleanly.
How unhandled rejections happen
// 1. Fire-and-forget without .catch():
sendWelcomeEmail(user.email) // Promise returned, nothing attached
// 2. async IIFE with no outer catch:
;(async () => {
await db.connect()
await runMigrations() // if this rejects, nobody catches it
})()
// 3. Promise created in a callback — lost from the async chain:
setTimeout(() => {
fetch('https://api.example.com/data')
.then(r => r.json()) // .catch() missing
}, 1000)The handler
process.on('unhandledRejection', (reason, promise) => {
// 'reason' is the rejection value (often an Error)
// 'promise' is the specific Promise that was rejected
logger.fatal(
{ reason, promise },
'Unhandled promise rejection — process will exit',
)
// Notify alerting:
// Sentry.captureException(reason)
// Exit — same rationale as uncaughtException:
process.exit(1)
})Finding and fixing the root cause
// The handler gives you the reason (the rejection value) and the promise.
// To trace WHERE the promise was created, look at reason.stack:
process.on('unhandledRejection', (reason) => {
if (reason instanceof Error) {
console.error('Stack:', reason.stack) // tells you where the error originated
} else {
console.error('Reason:', reason) // might be a string or other value
}
process.exit(1)
})
// Useful: --enable-source-maps flag shows original TS/ESM source in stacks:
// node --enable-source-maps dist/app.jsFixing the underlying problems
// ❌ Fire-and-forget — rejection silently lost:
sendWelcomeEmail(user.email)
// ✅ Await it:
await sendWelcomeEmail(user.email)
// ✅ If truly fire-and-forget (best effort), at least log the failure:
sendWelcomeEmail(user.email).catch(err =>
logger.warn({ err }, 'Welcome email failed — non-critical'),
)
// ❌ Async IIFE with no outer catch:
;(async () => { await startup() })()
// ✅ Catch at the entry point:
;(async () => { await startup() })().catch(err => {
logger.fatal({ err }, 'Startup failed')
process.exit(1)
})Startup vs runtime rejections
Phase | Pattern | On rejection |
|---|---|---|
Startup (top-level await / IIFE) |
| Crash fast — don't start a broken server |
Request handlers |
| Forward to |
Background jobs |
| Log, retry, or alert — don't crash on every failure |
Combining both process-level handlers
startup.js — register early, before any async work
function onFatalError(err, origin) {
logger.fatal({ err, origin }, 'Fatal process error — exiting')
process.exit(1)
}
process.on('uncaughtException', (err) => onFatalError(err, 'uncaughtException'))
process.on('unhandledRejection', (reason) => onFatalError(reason, 'unhandledRejection'))
// Register these BEFORE connecting to the database, starting the server, etc.