Understanding uncaughtException and unhandledRejection in Node.js
Hemanta Sundaray
Published
What happens when errors escape all your try/catch blocks and .catch() handlers?
Node.js provides process-level events as a last line of defense.
The uncaughtException Event
This event fires when a synchronous error escapes all try/catch blocks:
process.on("uncaughtException", function (err, origin) { console.error("================================="); console.error("UNCAUGHT EXCEPTION!"); console.error("Error:", err.message); console.error("Origin:", origin); console.error("Stack:", err.stack); console.error("=================================");
// Perform synchronous cleanup here // ...
// Then exit - continuing is NOT safe! process.exit(1);});
// This error will trigger the handlersetTimeout(function () { throw new Error("Unhandled error in callback");}, 1000);The correct use of uncaughtException is to perform synchronous cleanup of allocated resources before shutting down the process. It is not safe to resume normal operation after an uncaught exception.
Why? After an uncaught exception, your process may be in an undefined state:
- Variables could be corrupted
- Resources could be half-allocated
- Invariants could be broken
- Database transactions could be incomplete
Continuing execution risks data corruption and cascading failures.
The unhandledRejection Event
This event fires when a promise is rejected and there's no .catch() handler:
process.on("unhandledRejection", function (reason, promise) { console.error("================================="); console.error("UNHANDLED REJECTION!"); console.error("Reason:", reason); console.error("=================================");});
// This rejection will trigger the handlerPromise.reject(new Error("Nobody caught me"));
// So will thisasync function doSomething() { throw new Error("Async error");}doSomething(); // Called without .catch() or try/awaitThe rejectionHandled Event
This event fires when a promise rejection is handled after it was initially detected as unhandled:
process.on("unhandledRejection", function (reason, promise) { console.log("2. Rejection detected as unhandled");});
process.on("rejectionHandled", function (promise) { console.log("3. Rejection was handled late");});
// Create a rejected promise without handling itconst rejected = Promise.reject(new Error("Test"));
console.log("1. Setting up delayed handler...");
// Handle it latersetTimeout(function () { rejected.catch(function (err) { console.log("4. Finally caught:", err.message); });}, 1000);Output:
1. Setting up delayed handler...2. Rejection detected as unhandled4. Finally caught: Test3. Rejection was handled lateThese two events together let you track "potentially unhandled" rejections:
const pendingRejections = new Map<Promise<unknown>, unknown>();
process.on("unhandledRejection", function (reason, promise) { pendingRejections.set(promise, reason);});
process.on("rejectionHandled", function (promise) { pendingRejections.delete(promise);});
// Periodically check for truly unhandled rejectionssetInterval(function () { if (pendingRejections.size > 0) { console.error("TRULY UNHANDLED REJECTIONS:"); pendingRejections.forEach(function (reason) { console.error("-", reason); }); pendingRejections.clear(); }}, 5000);Modern Node.js Behavior (v15+)
Before Node.js 15, unhandled promise rejections only printed a warning but didn't crash the process. This was problematic because:
- Errors could go unnoticed in production
- Promise rejections were treated differently than exceptions
- Code could silently fail
Since Node.js 15, unhandled rejections crash the process by default, matching the behavior of uncaught exceptions:
// In Node.js 14 and earlier: Prints warning, continues running// In Node.js 15+: Crashes the processPromise.reject(new Error("This now crashes"));The --unhandled-rejections flag
You can control this behavior with a command-line flag:
# Default in Node.js 15+ - crash on unhandled rejectionnode --unhandled-rejections=throw app.js
# Strict mode - crash immediately (no grace period)node --unhandled-rejections=strict app.js
# Old behavior - warn but don't crashnode --unhandled-rejections=warn app.js
# Silent - no warnings, no crash (dangerous!)node --unhandled-rejections=none app.jsThe difference between throw and strict:
// With --unhandled-rejections=throw (default)const p = Promise.reject(new Error("test"));// Node.js waits until the next tick...p.catch(function () {}); // Handler attached in time! No crash.
// With --unhandled-rejections=strictconst p = Promise.reject(new Error("test")); // IMMEDIATE crashp.catch(function () {}); // Too late, never runs