NodeJSUnhandled Promise Rejections

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

JS
// 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)
From Node 15+, an unhandled rejection crashes the process — it's no longer just a warning
If you're used to seeing "UnhandledPromiseRejectionWarning" and the process continuing, that era is over in current Node. An unhandled rejection now terminates the process as if an exception was thrown — intentionally, because the alternative (silently ignoring errors) hides bugs. Audit your codebase for fire-and-forget `async` calls that aren't awaited or `.catch()`d. In Express, use an [asyncHandler](/nodejs/try-catch-async) wrapper so rejections from route handlers always reach `next(err)`.
The handler

JS
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)
})
The handler fires AFTER the event loop tick that rejected the promise
`unhandledRejection` is fired when a rejected Promise has no rejection handler attached by the end of the current microtask queue flush. That means a rejection where you *later* add a `.catch()` on the same tick is fine; it's genuinely abandoned rejections — nothing attached at all, or `.catch()` added asynchronously after — that trigger it. When the handler fires, treat it the same as `uncaughtException`: log, alert, and exit.
Finding and fixing the root cause

JS
// 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.js
Fixing the underlying problems

JS
// ❌ 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)
})
Every Promise must be awaited, returned, or have `.catch()` attached
The mental model: a Promise is a responsibility. You either `await` it (making your current function responsible for the rejection), `return` it (passing responsibility to the caller), or attach `.catch()` (explicitly handling it). Anything else is abandonment — the rejection has nowhere to go. Linters like `@typescript-eslint/no-floating-promises` or ESLint's `promise/catch-or-return` rule can detect abandoned Promises statically before they become runtime crashes.
Startup vs runtime rejections

Phase

Pattern

On rejection

Startup (top-level await / IIFE)

await connectDB() at boot

Crash fast — don't start a broken server

Request handlers

asyncHandler(async (req,res)=>{})

Forward to next(err) → error middleware

Background jobs

job().catch(err => logger.error(err))

Log, retry, or alert — don't crash on every failure

Combining both process-level handlers

startup.js — register early, before any async work

JS
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.
Next
Exit cleanly under all conditions: [Graceful Shutdown](/nodejs/graceful-shutdown).