Handling Unhandled Promise Rejections in Modern JS
Unhandled promise rejections bypass synchronous error boundaries, causing silent state corruption and unpredictable runtime crashes. This guide details interception, diagnosis, and resolution strategies across browser and Node.js environments. It establishes a robust telemetry pipeline for Core JavaScript Error Handling & Boundaries.
Key implementation targets:
- Event-driven interception via
unhandledrejectionandprocess.on('unhandledRejection') - Source map resolution for minified async stack traces
- Environment parity between dev, staging, and production error routing
- Payload optimization strategies for rejection telemetry
Browser-Side Interception & Event Configuration
Attach the listener before framework hydration to capture early async failures during module loading. Extract event.reason for error classification and event.promise for reference tracking. Call event.preventDefault() in production to suppress default console noise while routing telemetry.
Pair this configuration with Mastering window.onerror and Global Event Listeners to establish a synchronous fallback for non-promise exceptions.
window.addEventListener('unhandledrejection', (event) => {
event.preventDefault();
const reason = event.reason instanceof Error ? event.reason : new Error(String(event.reason));
telemetry.capture(reason, { promiseState: 'rejected', context: 'global' });
});
Explanation: Prevents console spam, normalizes non-Error rejections, and forwards to observability layer.
Node.js Process-Level Handling & Graceful Degradation
Node.js treats unhandled rejections as critical process anomalies. Configure --unhandled-rejections=strict in CI/CD pipelines to fail builds on uncaught async errors. Differentiate recoverable contexts from fatal ones using custom error codes.
Implement AsyncLocalStorage to attach request-scoped metadata to rejection payloads. Always enforce connection draining before termination to prevent data corruption.
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled Rejection', { reason, stack: reason?.stack });
if (process.env.NODE_ENV === 'production') {
server.close(() => process.exit(1));
}
});
Explanation: Logs context, triggers connection drain, and enforces strict process termination in prod.
Async Loop & Concurrency Error Containment
Concurrent execution patterns require explicit rejection isolation to prevent cascade failures. Replace Promise.all with Promise.allSettled when partial success is acceptable. Wrap concurrent iterators with rejection-safe wrappers to maintain loop continuity.
Apply Best practices for try/catch in async loops for batch processing resilience. Implement backpressure-aware error queuing for high-throughput operations. Validate promise state transitions before resolution to avoid silent drops.
for await (const task of asyncIterator) {
try {
await task.execute();
} catch (err) {
if (err instanceof AbortError) continue;
rejectionQueue.push({ task: task.id, error: err, timestamp: Date.now() });
}
}
Explanation: Contains failures per iteration, prevents loop termination, and queues metadata for batch reporting.
Framework Integration & UI Boundary Routing
Global rejection events must bridge into component-level error boundaries to maintain UI integrity. Map async component loading failures to localized fallback states. Prevent unhandled rejections from corrupting hydration state or global stores.
Integrate rejection routing with Implementing React Error Boundaries for Production to isolate async failures at the render tree level. Configure rejection-aware Suspense boundaries with explicit timeout thresholds.
Telemetry Payload Optimization & Stack Trace Reconstruction
Raw rejection payloads often contain circular references that crash JSON serializers. Extract Error.prototype properties safely to prevent memory leaks. Reconstruct async call stacks using Error.captureStackTrace and the V8 stack trace API.
Enforce strict schema validation on metadata before transmission. Follow How to log custom error properties without blooming payloads to optimize bandwidth and indexing efficiency.
Common Mistakes
Ignoring non-Error rejection values
Promises can reject with strings, null, or custom objects. Failing to normalize event.reason breaks stack trace extraction and observability parsers. Always coerce to Error instances before serialization.
Overusing Promise.all without allSettled fallback
A single rejection in Promise.all aborts the entire batch. If not caught synchronously, it triggers unhandled rejection warnings and drops partial results. Use Promise.allSettled for independent async tasks.
Blocking the event loop during rejection handling
Synchronous heavy logging or DB writes inside unhandledRejection handlers cause process stalls. Under load, this triggers cascade failures. Offload telemetry to non-blocking queues or async sinks.
FAQ
Why does unhandledrejection fire after a .catch() is attached?
The event triggers if the rejection is not handled synchronously within the same microtask queue cycle. Late .catch() attachment does not retroactively suppress the event.
How do I preserve async stack traces in minified production builds?
Deploy source maps to the observability platform, enable --enable-source-maps in Node.js, and configure Error.stackTraceLimit to capture deeper async frames.
Should unhandledRejection handlers exit the Node.js process?
Yes, in production. Unhandled rejections leave the process in an undefined state. Graceful shutdown with connection draining is the recommended SRE practice.
Can unhandledrejection be used to recover from framework hydration errors?
No. It should only be used for telemetry and fallback routing. Recovery requires framework-specific boundaries like React Suspense or Vue error components.