Debugging Race Conditions in Async Error Handlers
When two unhandled promise rejections fire simultaneously, their handlers both read shared state before either one writes to it — a classic time-of-check/time-of-use (TOCTOU) flaw that leads to duplicate process.exit() calls, silenced errors, and corrupted cleanup state. This page works through the exact timing mechanics in the context of Node.js uncaughtException vs unhandledRejection and the broader landscape of Core JavaScript Error Handling & Boundaries.
Symptom / Trigger
The most visible sign of this race is duplicate shutdown log lines appearing within one or two milliseconds of each other, often with conflicting exit codes. Here is what it looks like when two unhandledRejection listeners both slip through an unguarded boolean check:
// Terminal output when the race fires
[2026-06-19T10:42:01.003Z] Shutting down due to: DB connection lost
[2026-06-19T10:42:01.004Z] Shutting down due to: Cache flush failed
[2026-06-19T10:42:01.004Z] process.exit(1) called // handler A
[2026-06-19T10:42:01.004Z] process.exit(0) called // handler B — overwrites exit code!
// exit hooks (beforeExit, 'exit' event) fire twice
// telemetry flush runs twice, sending duplicate crash reports
The second process.exit() call wins on some platforms, replacing exit code 1 with 0, which makes the process appear to have succeeded. The exit event listeners run twice, which means cleanup functions — database connection teardown, log flushing, metric emission — all execute a second time, often against already-closed handles, producing secondary uncaught errors.
Root Cause Explanation
The broken pattern reads a shared flag and then, in a later microtask tick, writes it. Because both handlers are enqueued in the same microtask checkpoint, both read false before either writes true:
// Broken: TOCTOU race — both handlers read false before either writes true
let isShuttingDown = false;
process.on('unhandledRejection', async (reason) => {
if (isShuttingDown) return; // A and B both pass this check …
isShuttingDown = true; // … before either reaches this write
await flushTelemetry(reason); // async gap — other handler runs here
process.exit(1);
});
The await flushTelemetry() suspends handler A and hands control back to the event loop. Handler B is already queued and runs immediately. Because isShuttingDown is still false when B evaluates the guard, both handlers reach process.exit(). A parallel Promise.all makes this worse: it short-circuits on the first rejection, silently discarding the reason for all subsequent rejected promises, so you lose the error context you need to diagnose the root cause.
Step-by-Step Fix
1. Synchronous boolean guard — set the flag before any await
The guard write must happen synchronously on the same tick as the read. Moving isShuttingDown = true to before the first await closes the TOCTOU window entirely in single-threaded Node.js, because no other handler can run between synchronous statements:
// Fix 1: synchronous write closes the TOCTOU window
let isShuttingDown = false;
process.on('unhandledRejection', async (reason) => {
if (isShuttingDown) return;
isShuttingDown = true; // synchronous — set BEFORE first await
console.error('Shutting down due to:', reason?.message ?? reason);
try {
await flushTelemetry(reason); // safe: flag already set
} finally {
process.exit(1);
}
});
This works because JavaScript’s event loop cannot interleave two synchronous code paths. The read and the write are on consecutive lines with no await between them, so no other microtask can fire in between.
2. Replace Promise.all with Promise.allSettled to capture every failure
When multiple promises run in parallel, use Promise.allSettled so that all rejections are visible instead of only the first one:
// Fix 2: collect all failures, not just the first
async function runCriticalWork() {
const results = await Promise.allSettled([
connectDatabase(),
warmCache(),
loadConfig(),
]);
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
// Log every failure before deciding to exit
for (const f of failures) {
console.error('Task failed:', f.reason?.message ?? f.reason);
}
process.exit(1);
}
}
Promise.allSettled always waits for every promise to settle, so you get the full picture before taking action. This eliminates the category of bugs where a secondary rejection becomes unhandled because Promise.all discarded it.
3. Counter-based aggregation for multiple concurrent unhandledRejection events
When you cannot control the source of concurrent rejections — for example, third-party libraries emitting them — a simple counter lets you aggregate all errors that arrive within a single event-loop tick before deciding to exit:
// Fix 3: aggregate all rejections that arrive before the exit flush
let isShuttingDown = false;
const pendingErrors = [];
process.on('unhandledRejection', (reason) => {
pendingErrors.push(reason); // collect synchronously
if (isShuttingDown) return;
isShuttingDown = true; // synchronous guard
// Defer to next tick so any same-tick rejections can join pendingErrors
setImmediate(async () => {
console.error(`Shutting down with ${pendingErrors.length} error(s):`);
pendingErrors.forEach((e, i) => console.error(` [${i + 1}]`, e?.message ?? e));
try {
await flushTelemetry(pendingErrors);
} finally {
process.exit(1);
}
});
});
setImmediate defers the actual exit until the current event-loop phase is fully drained. Every rejection that fired synchronously in the same tick is already in pendingErrors by the time the setImmediate callback runs.
4. Use AbortController to cancel dependent async branches on first failure
If your concurrent work has branches that should stop as soon as one fails, wire an AbortController through them rather than letting them run to completion and produce additional unhandled rejections:
// Fix 4: AbortController prevents cascading rejections from dependent tasks
async function runWithCancellation() {
const controller = new AbortController();
const { signal } = controller;
try {
await Promise.all([
primaryTask(signal),
secondaryTask(signal), // receives cancellation signal
]);
} catch (err) {
controller.abort(err); // cancel remaining work on first failure
throw err;
}
}
async function secondaryTask(signal) {
signal.throwIfAborted(); // exits immediately if already cancelled
await heavyIO(signal); // passes signal to fetch/fs calls
}
By aborting the controller on the first caught error, dependent tasks terminate cleanly through signal.throwIfAborted() rather than completing and emitting their own unhandled rejections into the global handler.
Verification
Run the following script and confirm that only one “Shutting down” line appears in the output, and that the process exits with code 1:
// verification.js — expect exactly one shutdown line, exit code 1
let isShuttingDown = false;
const pendingErrors = [];
process.on('unhandledRejection', (reason) => {
pendingErrors.push(reason);
if (isShuttingDown) return;
isShuttingDown = true;
setImmediate(() => {
console.log('Shutting down:', pendingErrors.map(e => e.message));
process.exit(1);
});
});
// Trigger two concurrent rejections
Promise.reject(new Error('DB Timeout'));
Promise.reject(new Error('Cache Miss'));
// Expected output (both errors collected, one shutdown):
// Shutting down: [ 'DB Timeout', 'Cache Miss' ]
// (process exits with code 1)
Verify with node verification.js; echo "Exit code: $?". You should see a single Shutting down: line listing both errors and Exit code: 1 printed by the shell.
Edge Cases & Gotchas
-
Worker threads need
Atomics— The synchronous boolean trick only works because Node.js is single-threaded per isolate. If you spin upworker_threadsand share aSharedArrayBuffer, you needAtomics.compareExchangeto guarantee atomicity:Atomics.compareExchange(flagArray, 0, 0, 1)returns the old value, so a return of0means you won the race and should proceed;1means another thread already set the flag. -
process.exit()inside anasynchandler is not awaited by the caller — If youawait someAsyncCleanup()inside yourunhandledRejectionhandler and that async work itself throws, you get a second unhandled rejection with no handler, becauseisShuttingDownis alreadytrue. Wrap everyawaitinside the exit path with atry/catchthat callsprocess.exit()in thecatchblock. -
beforeExitvsexitevent semantics —process.exit()called twice skips any pendingbeforeExitlisteners on the second call (the process is already in exit state), butexitevent listeners run twice if registered more than once. De-registerexitlisteners after first invocation or use a one-time guard to prevent duplicate side effects like double-flushing metrics. -
Promise.allSettledis ES2020+ — On Node.js 10 or earlier,Promise.allSettledis not available. Usepromise.catch(e => ({ status: 'rejected', reason: e }))to homogenise the result shape manually, or upgrade Node.js, since v10 reached end-of-life in 2021.
FAQ
Why does my exit code come out as 0 even though I called process.exit(1) first?
Two process.exit() calls race. On some Node.js versions, the second call wins the internal exit code latch. The synchronous boolean guard in Fix 1 above ensures only one call ever reaches process.exit(), so the first — and only — code you set is the one that sticks.
Can I use async functions as unhandledRejection handlers safely?
Yes, but with care. The handler itself must set the isShuttingDown flag synchronously before the first await. Any await inside the handler suspends it and lets other events fire. If those events include another rejection, the guard must already be set or you get the race again. Never put the guard write after an await.
What is the difference between this race and the one described in handling unhandled promise rejections?
The handling unhandled promise rejections page covers which promises trigger the global listener and how to attach .catch() correctly. This page is about what happens inside the handler once multiple rejections have already arrived at the same time — the synchronization problem between handler invocations rather than the subscription problem of which events reach the handler.
Related
- Node.js
uncaughtExceptionvsunhandledRejection— lifecycle differences between the two process-level error events - Implementing Graceful Shutdown on
uncaughtException— full shutdown sequencing with drain timeouts and signal handling - Handling Unhandled Promise Rejections in Modern JS — how rejections propagate to the global listener and how to prevent them
- Core JavaScript Error Handling & Boundaries — overview of the full error-handling surface in JavaScript runtimes