unhandledRejection vs uncaughtException: Event Loop Termination
- Last updated
- Prerequisites:
- Node.js promises and async/await
- Understanding of the event loop and process lifecycle
- nodejs
- unhandled-rejection
- uncaught-exception
- errors
- process
- exit
Read at your depth
The practical view
uncaughtException fires when an exception is thrown in synchronous code and never caught: process.on('uncaughtException', (err) => { ... }). unhandledRejection fires when a Promise rejects and no .catch() or try/catch (via await) handles it: process.on('unhandledRejection', (reason, promise) => { ... }). Since Node 15, an unhandled rejection CRASHES the process by default (before, it only warned). The recommended pattern: crash and restart (let the process manager restart you), or at most log and gracefully shut down — do NOT try to continue running after an uncaught exception because the state may be corrupt.
The same idea in other frameworks
N/A — Node.js runtime concern.N/A — Node.js concern.N/A — Node.js concern.N/A — Node.js concern.Legacy vs modern
Silently ignoring a rejected promise vs handling it
An unhandled rejection crashes the process by default; attaching a catch (or handling the event) keeps the app from terminating unexpectedly.
async function run() {
const data = await fetchData(); // ❌ OLD: if fetchData rejects and no catch...
use(data);
}try {
const data = await fetchData(); // ✅ NEW: handled
use(data);
} catch (err) {
log.error(err); // ✅ NEW: rejections handled at the source
}Interview gotchas
Context
This tests the error-model distinction — interviewers want the sync-vs-async error paths and the state-corruption rationale for the 'log and exit' guidance.
The mechanical answer
uncaughtException is a synchronous exception that escaped the call stack — the stack unwound mid-operation, so locks, invariants, transaction state, or middleware may be left inconsistent. Continuing execution from that point is unsupported and can corrupt data or hang, so the guidance is to log the error and exit (letting the process manager restart). unhandledRejection is a rejected Promise with no handler — the rejection was detected asynchronously (at the microtask checkpoint). The app's synchronous state may be perfectly fine, but the code that produced the rejection intended to handle it, so continuing means that logic silently failed. Node's default (v15+) is to terminate on unhandled rejections, matching the uncaught-exception behavior. The shared rule: if you handle either event, you should still exit rather than resume, because you cannot know how far the failure propagated.
Trap
The naive answer is 'just add a global handler and continue'. Resuming after uncaughtException is explicitly unsupported (undefined behavior); after unhandledRejection, the rejecting operation failed silently. Another trap: thinking catching an await inside one function protects the whole app — a promise that escapes (e.g. returned from an event handler nobody awaits) can still reject unhandled. Candidates who describe the state-corruption rationale show real understanding.
Context
This probes the evolution and the late-handler case — interviewers want the crash-by-default rationale and the subtle 'handled too late' scenario.
The mechanical answer
Before Node 15, an unhandled rejection only printed a warning and the process continued. This hid bugs: a rejected promise with no handler meant some code path silently failed (a failed request, a lost write) while the app kept running, making failures invisible in production. Node changed the default to terminate the process (like an uncaught exception) so that silent async failures surface immediately and the orchestrator restarts the process. The 'rejectionHandled' event fires when a handler is attached to a promise AFTER the unhandledRejection event already fired (e.g. a .catch added later) — it lets you know the 'unhandled' report was a false alarm (the handler arrived late). This late-handling scenario is why the unhandled event can fire even for promises that eventually get caught — the check happens at the end of the current turn.
Trap
The naive answer is 'a .catch anywhere fixes it'. If the catch is attached AFTER the turn where the rejection was detected, unhandledRejection still fired (and rejectionHandled then fires). Another trap: thinking the crash default can be safely disabled globally — suppressing it just re-hides the silent failures Node made visible. Candidates who describe the turn-based detection and the late-handler event show real depth.