Node.js uncaughtException vs unhandledRejection
This guide dissects the runtime boundaries between synchronous throws and asynchronous promise rejections in Node.js. Unlike browser environments where Mastering window.onerror and Global Event Listeners applies, Node.js isolates these events across distinct V8 microtask and macrotask queues. Proper configuration of Core JavaScript Error Handling & Boundaries ensures observability pipelines capture accurate stack traces without masking critical failures.
Key implementation objectives:
- Differentiate V8 synchronous exception propagation from Promise microtask queue failures
- Map handler execution to specific Node.js event loop phases (timers, poll, check)
- Enforce strict exit code management to prevent zombie processes
Event Loop Phase Isolation & Handler Registration
Global interceptors must be registered synchronously before any asynchronous I/O initialization. Attaching process.on('uncaughtException') late in the boot sequence risks missing synchronous bootstrap failures.
Configure process.on('unhandledRejection') alongside --trace-warnings and --trace-uncaught CLI flags. These flags force V8 to emit verbose diagnostics directly to stderr. Align your handler logic with Handling Unhandled Promise Rejections in Modern JS to standardize rejection payloads across runtimes.
Implement request context isolation using AsyncLocalStorage. This preserves trace IDs across async boundaries without relying on global state.
const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();
let isShuttingDown = false;
process.on('uncaughtException', (err, origin) => {
if (isShuttingDown) return;
const ctx = asyncLocalStorage.getStore() || {};
console.error(`[${ctx.reqId}] Uncaught Exception [${origin}]:`, err.stack);
gracefulShutdown(1);
});
process.on('unhandledRejection', (reason, promise) => {
if (isShuttingDown) return;
const ctx = asyncLocalStorage.getStore() || {};
console.error(`[${ctx.reqId}] Unhandled Rejection:`, reason);
gracefulShutdown(1);
});
function gracefulShutdown(code) {
isShuttingDown = true;
process.exitCode = code;
server.close(() => process.exit(code));
setTimeout(() => process.exit(code), 5000);
}
This implementation demonstrates atomic state management to prevent duplicate shutdown triggers. It preserves request context via AsyncLocalStorage and enforces deterministic exit codes for container orchestrators.
Async Stack Trace Reconstruction & Source Map Mapping
Fragmented call stacks require explicit configuration to remain actionable in production. Enable Error.stackTraceLimit = Infinity during development to capture deep async chains. Use Error.captureStackTrace(targetObject, constructorOpt) in production to strip framework boilerplate from logs.
Configure source-map-support/register with strict environment targeting. Set handleUncaughtExceptions: false to avoid double-interception. Parse err.stack using regex boundaries (at\s+.*\((.*):(\d+):(\d+)\)) to extract exact file paths, line numbers, and columns for sourcemap resolution.
require('source-map-support').install({
environment: 'node',
handleUncaughtExceptions: false,
hookRequire: true
});
Error.stackTraceLimit = 50;
function captureCleanStack(err) {
Error.captureStackTrace(err, captureCleanStack);
return err;
}
This configuration disables automatic exception interception in source-map-support. It prevents conflicts with manual uncaughtException handlers while increasing stack depth. The utility function provides framework-level stack trimming for cleaner log aggregation.
Graceful Process Termination & Exit Code Management
Deterministic shutdown sequences must flush logs, drain connections, and signal orchestrators correctly. Set process.exitCode = 1 immediately upon unhandled error capture. This overrides the default exit status before the process terminates.
Implement connection draining via server.close(). Pair it with a hard timeout to prevent indefinite hangs. Offload log flushing to setImmediate or worker threads to avoid blocking the event loop during critical teardown phases.
Mitigate Debugging race conditions in async error handlers by using atomic state flags. A boolean guard like isShuttingDown prevents concurrent exit calls from corrupting the termination sequence.
Common Mistakes
-
Swallowing errors without setting
process.exitCodeFailing to assignprocess.exitCoderesults in a0exit status. Kubernetes and ECS health checks falsely report success. Corrupted state remains in memory until manual intervention. -
Blocking the event loop during error logging Synchronous
fs.writeFileSyncor heavy JSON serialization inside error handlers stalls the event loop. Connection drains fail. Orchestrators trigger OOM or timeout kills. -
Enabling
source-map-supporthandleUncaughtExceptionsalongside manual handlers Double-interception causes duplicate log entries. Shutdown sequences race. Stack trace formatting becomes unpredictable due to conflicting V8 error formatting hooks.
FAQ
Why does unhandledRejection fire after uncaughtException in certain async flows?
V8 microtask queue execution defers promise rejection callbacks until the current synchronous stack unwinds. A sync throw interrupts the call stack before the microtask checkpoint. The rejection queues and fires asynchronously, triggering unhandledRejection after the initial exception handler.
Should I use process.exit(0) after logging an uncaughtException?
Never. Always use process.exitCode = 1 followed by process.exit(code) after draining connections. Exiting with 0 signals success to process managers. This masks failures and prevents automatic restarts or alerting.
How do I prevent double-logging when using third-party error tracking SDKs?
Disable the SDK’s automatic uncaughtException and unhandledRejection interception via configuration flags. Manually invoke the SDK’s capture method within your custom handlers. This maintains execution order and preserves context isolation.