Unhandled Promise Rejection Crashing Node? Fix It
Stop unhandled promise rejections by awaiting with try/catch, adding .catch(), and wrapping Express routes the right way..
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
- ✓Comfort with async/await and promises
- ✓Basic Express routing knowledge
- ✓A Node 18+ API project to audit
- Every promise needs an owner: await it inside try/catch or end the chain with .catch() so failures can't go unhandled
- Modern Node crashes on unhandled rejections by default, so one missed catch can restart your whole process
- Use process.on('unhandledRejection') only to log and locate the source, never as a substitute for real handling
- Wrap async Express routes so rejected promises reach next(err) instead of hanging the request
Imagine ordering food delivery and then leaving the house with your phone off. The driver arrives, can't reach you, and the food sits on the doorstep getting cold. An unhandled rejection is the same: your code started an async job, left no failure instructions, and Node holds a failed delivery with nowhere to send it. Modern Node shuts down the whole service over it. The fix is simple — always leave a contact number by handling every async failure.
Your API runs fine for weeks, then restarts at 3 AM with a single log line: UnhandledPromiseRejection. No request context. No user ID. Just a rejected promise nobody claimed, and Node's default response — crash the process. PM2 restarts it, traffic resumes, and everyone hopes it won't happen again. It will, because the missing handler is still in the code.
This error is uniquely frustrating because the crash site tells you nothing about the bug site. The rejection originates in one async function, floats through the event loop unobserved, and kills the process far from where the mistake lives. Developers add a process-level handler, see the crashes stop getting logged, and declare victory — while requests keep hanging and data keeps half-writing.
This guide shows the full fix. You'll learn why modern Node crashes by default, how to find the unclaimed promise with one handler used strictly as a locator, and the wrapper patterns that route async errors into Express middleware. Real incident, real commands, permanent fix.
Why Missing .catch and try/await Crashes Everything
Every promise needs exactly one owner for its failure path. For awaited promises the owner is the surrounding try/catch. For chained promises it's a terminal .catch() or a rejection handler passed as .then's second argument. Miss both and the rejection floats: no code observes it, no fallback runs, and at the microtask checkpoint Node flags it unhandled. A single floating promise in a helper called once per hour is enough to crash a service that otherwise handles millions of requests.
The treacherous variants are the ones that look handled. Returning a promise from a non-async callback without chaining leaves it floating. A .then() with only a success handler ignores rejections. Promise.all rejects wholesale on the first failure, discarding the rest. Audit async code by tracing each promise to its owner — if you can't name the catch, you've found the crash. TypeScript's no-floating-promises rule automates exactly this audit.
Timer and event callbacks hide the most floaters. setTimeout(async () => {...}) without an inner try/catch floats its rejection past every outer handler, and EventEmitter listeners that return promises (instead of handling within) do the same on every emit. Array.map with async callbacks is another trap: the mapped promises only stay owned if you await Promise.all on the result — forEach with async callbacks floats every single one, always. Promise.race and Promise.any add subtlety: losers that later reject still need handlers, or they surface as unhandled long after the race settled. Enable the no-floating-promises rule and the no-misused-promises rule together; the pair catches detached chains, unawaited async arguments, and conditional promises assigned but never returned. You'll turn a whole category of invisible crashes into red squiggles in the editor.
Crash by Default: Why Modern Node Refuses to Continue
Before Node 15, unhandled rejections printed a warning and kept running. Services survived — with silently skipped writes, half-updated caches, and phantom successes. The default changed because the working group concluded an ambiguous program state is more dangerous than downtime. A crashed container gets replaced by a healthy one in seconds; a surviving-but-corrupt process serves wrong answers indefinitely.
Don't fight this with --unhandled-rejections=warn. The flag converts a diagnosable crash into invisible corruption and teaches the team to ignore rejection warnings. Instead, design for crash-only recovery: keep handlers stateless, persist in-flight work in a queue, and let the orchestrator restart fast. Your process-level listener should log the rejection with full context and exit nonzero. Treat every unhandled rejection as a bug report, not a runtime condition to tolerate.
Crash-only recovery shapes how you design state. Keep request handlers stateless so any process can serve any retry; push in-flight work into durable queues (BullMQ, SQS) before acknowledging it; store session data in Redis rather than process memory so a restart loses nothing visible. With that architecture, a crash costs one orchestrator restart — seconds — instead of corrupted state that poisons hours of traffic. Tune restart policy deliberately: RestartAlways with backoff in Kubernetes, plus crash-loop alerts that fire when restarts exceed three in ten minutes (a crash loop signals a deterministic bug, not a blip). Test the path with chaos drills: inject a rejection in staging and verify traffic recovers without manual steps. Teams that rehearse crash recovery stop fearing the crash log and start treating it as the bug report it is.
The Locator Handler: Finding the Source in Minutes
A process-level unhandledRejection listener is a diagnostic instrument, not a fix. Its job is capturing the rejection reason, the promise object, and ideally the async context (request ID, user ID) before exiting. Run staging with --trace-warnings to attach creation stacks, and ship request-scoped context via AsyncLocalStorage so the locator log names the route and user. Without context propagation, the log shows a bare StripeConnectionError and you're grep-hunting.
Build the locator once and reuse it: serialize reason stacks, include memory and uptime for crash-loop detection, and flush logs synchronously before exit. In Kubernetes, a nonzero exit triggers a restart with the log already shipped — the replacement serves traffic while you read the stack. Delete any handler that resolves or ignores; if code review can't tell locator from suppressor, name the function logAndExitOnUnhandled.
AsyncLocalStorage is what makes locator logs worth reading. Create one async context per incoming request carrying request ID, route, user ID, and start time; the unhandledRejection listener then reads the current store and attaches it to the crash log. Without this, concurrent requests blur into one anonymous stack — with it, the log names the exact request that spawned the floater. Propagate the same context into outbound calls and queue jobs so the trail survives service boundaries. Keep the store small (IDs and timestamps, not bodies) to avoid memory overhead in hot paths. Add a unit test that triggers a rejection inside a stubbed context and asserts the locator output contains the request ID — untested instrumentation rots, and a silent locator is worse than none because the team assumes coverage it doesn't have.
Async Route Wrappers: The Express Pattern That Ends Hangs
Express 4 invokes your handler and ignores a returned promise. An async route that throws rejects into the void: no error middleware runs, the client hangs until timeout, and Node crashes separately on the unhandled rejection. Two failures for the price of one missing wrapper. Every async handler must bridge its rejection into next(err) explicitly.
The standard wrapper is five lines: a higher-order function catching the promise and forwarding to next. Apply it at route definition so no handler can forget, or adopt express-async-errors to patch the router globally. Express 5 handles async rejections natively, so upgrading removes the category — but until then, the wrapper is mandatory. Audit with a grep for bare async handlers and convert each one; partial coverage still crashes.
Middleware chains need the same treatment as routes. Async auth, validation, and logging middleware that throw will hang requests identically to bare routes, so wrap app.use handlers with the same asyncWrap helper — or better, wrap at registration through a tiny router shim that every file imports instead of raw express.Router. Audit coverage mechanically: grep for 'async (req' and 'async(req' across routes and middleware, and fail CI when an unwrapped match appears outside the wrapper. For WebSocket and queue-consumer boundaries, apply the identical pattern (catch plus dead-letter) since those frameworks also ignore returned promises. Upgrading to Express 5 removes the route-level need but not the discipline — background handlers in any framework still need owners. One shared wrapper module plus a lint gate ends the category across every boundary your service owns.
Fire-and-Forget Promises: Queues Instead of Floats
Background work — audit logs, analytics pings, cache warmups — tempts developers to call the async function and move on. That floating promise has no owner; its rejection crashes the process from a code path nobody associates with the request. The fix isn't awaiting it (that would slow the response) but giving it an owner that isn't the request: a .catch() that logs, or better, a durable queue.
For truly optional side effects, attach .catch with structured logging so failures are visible without being fatal. For anything that must eventually succeed — order records, webhook acknowledgments, billing events — enqueue in BullMQ or SQS and acknowledge only after persistence. Queues convert unhandled rejections into retried jobs with backoff and dead-letter visibility. If losing the side effect is acceptable, .catch suffices; if it isn't, it was never fire-and-forget.
Queue configuration decides whether the safety net holds under real failure. Set explicit attempts (5 for billing-critical, 3 for best-effort), exponential backoff with jitter to avoid thundering herds on recovery, and a dead-letter queue with alerts so permanently failing jobs page a human instead of retrying forever. Make jobs idempotent — keyed by event ID with dedupe on the consumer — because at-least-once delivery replays jobs after crashes, and a non-idempotent audit writer double-counts exactly when you're investigating an incident. Monitor queue depth and age: growing depth means consumers are down (scale or fix), while aging head items mean poison messages blocking the queue (dead-letter them). For optional pings, a bare .catch with a debug log suffices — but revisit any .catch that stays silent for months, since quiet failures compound into the data gaps you'll discover during audits.
Retrying Right: Backoff, Timeouts, and Idempotency
Most rejections worth handling come from the network: timeouts, resets, 5xx responses. Blind retries amplify outages; structured retries with exponential backoff, jitter, and AbortSignal timeouts absorb them. Cap attempts (3-5 for user-facing paths), bound total elapsed time under the client's patience, and always send idempotency keys on mutating calls so a retried charge returns the original instead of duplicating it.
Separate retryable errors (ETIMEDOUT, ECONNRESET, 503) from terminal ones (400, 401, validation failures) and retry only the former. Log each attempt with attempt number and elapsed time so dashboards show retry storms forming. Combine with circuit breakers when a dependency stays down: fail fast with 502 instead of stacking doomed attempts. Handled rejections with this discipline turn 3 AM pages into boring metrics.
Circuit breakers complete the retry story. After consecutive failures pass a threshold (say 50% over 30 seconds), the breaker opens and calls fail fast with 502 instead of consuming threads on a known-dead dependency — preserving your pool for recovery instead of stacking doomed attempts. Half-open probes (one trial request per interval) detect recovery without a retry storm, closing the breaker when success returns. Combine breakers with hedged requests for latency-sensitive reads: send the same GET to two endpoints after the 95th-percentile wait, and take whichever answers first — you trade a little extra load for dramatically lower tail latency. Log breaker state transitions as events, not metrics, so incident timelines show exactly when the dependency was declared dead and when it recovered. Retries handle blips; breakers handle outages; hedging handles slowness — deploy all three and 3 AM stays quiet.
One Missing Catch Double-Charged 214 Customers at 3 AM
- Never let a process-level rejection handler keep serving traffic. Log, capture context, and exit — a fresh process with known state beats a surviving one with unknown corruption.
- Send idempotency keys on every mutating external call. Retries are guaranteed in webhook systems; only idempotency makes them safe to receive twice.
- Wrap every async framework boundary. Express 4 won't forward rejected promises, so one unwrapped route can hang requests and crash the process simultaneously.
| File | Command / Code | Purpose |
|---|---|---|
| src | async function chargeOrder(order) { | Why Missing .catch and try/await Crashes Everything |
| src | process.on('unhandledRejection', (reason, promise) => { | Crash by Default |
| node --trace-warnings --unhandled-rejections=strict server.js 2>&1 | head -30 | The Locator Handler | |
| src | const asyncWrap = (fn) => (req, res, next) => { | Async Route Wrappers |
| src | const { Queue } = require('bullmq'); | Fire-and-Forget Promises |
| node -e "fetch('https://httpbin.org/delay/10', { signal: AbortSignal.timeout(300... | Retrying Right |
Key takeaways
Common mistakes to avoid
6 patternsUsing the process handler as the fix instead of a locator
Leaving Express 4 async routes unwrapped
Floating fire-and-forget promises for critical writes
Running Node with --unhandled-rejections=warn
Retrying payments without idempotency keys
Catching errors without logging the request context
Interview Questions on This Topic
What exactly makes a promise rejection unhandled?
Frequently Asked Questions
20+ years shipping production JavaScript and front-end systems at scale. Drawn from code that ran under real load.
That's Node.js. Mark it forged?
7 min read · try the examples if you haven't