Node.js / Editorial solution guide
Unhandled rejections: restore ownership of asynchronous work
Find detached promises, place a meaningful error boundary, and verify both failure handling and resource cleanup.
Read-only knowledge base · No public comments
Recognize the symptoms
An asynchronous operation fails after its caller reported success, a rejection reaches process-level handling, or a request hangs after an exception. Under the default behavior of the verified modern runtime, an unhandled rejection can become an uncaught exception and terminate the process. Runtime flags can affect policy, so record the actual launch command instead of assuming historical warning-only behavior.
Understand the likely causes
- A helper started a promise without returning it, a caller forgot await, an async callback was passed to an API that ignores its returned promise, or an error path threw while handling the original failure. A surrounding synchronous try/catch catches only work actually awaited or thrown within its execution. It cannot retroactively own an unrelated detached task.
Trace the promise chain
Start from the rejection stack and find the operation that originally created the promise. Follow every caller upward: does it return the promise, await it, or explicitly own a background task? Pay attention to array forEach with async callbacks, timer callbacks, and EventEmitter listeners, because their consumers do not necessarily await a returned promise. Record a minimal synthetic reproduction rather than copying sensitive request data. Do not add empty catch handlers everywhere; that hides the failure while preserving the incorrect success path.
Restore a useful boundary
The non-runnable fragment intentionally illustrates a detached call: the surrounding try block ends before the rejected promise is observed. The complete repair awaits the operation inside an async boundary, classifies its stable error code, and runs finally cleanup. Put the boundary where a failure can become an appropriate HTTP response, CLI exit status, or job result. A low-level function should usually add context and rethrow, not pretend the operation succeeded. Preserve error cause when wrapping so the original diagnostic evidence remains available.
// Intentionally broken illustration; do not run.
async function save() { throw new Error('Write failed'); }
try { save(); } catch (error) { console.error(error.message); }
save returns a rejected promise; this synchronous catch does not await or observe it. The fragment is deliberately non-runnable.
async function save() {
const error = new Error('Synthetic write failure'); error.code = 'WRITE_FAILED'; throw error;
}
let released = false;
try { await save(); }
catch (error) {
if (error.code !== 'WRITE_FAILED') throw error;
console.log('handled WRITE_FAILED');
} finally { released = true; }
console.log('released', released);
handled WRITE_FAILED
released true
The boundary awaits the rejection and handles only its known failure code. finally runs after the handled failure.
Handle groups and background work deliberately
Use for...of with await for intentional sequencing or a bounded concurrency pattern for independent work. Promise.all observes a group but does not cancel remaining tasks after one fails. A genuine background task needs a supervisor that records its outcome and owns cancellation and shutdown; merely omitting await is not a lifecycle design. The second complete example uses allSettled for a small group whose individual outcomes all matter. In a service, also limit active tasks and waiting capacity instead of launching an arbitrary number of promises.
const tasks = [Promise.resolve('saved'), Promise.reject(new Error('Synthetic failure'))];
const outcomes = await Promise.allSettled(tasks);
for (const outcome of outcomes) {
console.log(outcome.status === 'fulfilled' ? `ok: ${outcome.value}` : 'failed: recorded');
}
ok: saved
failed: recorded
Both outcomes are observed in one turn. The rejected task is not detached, and every result receives an explicit disposition.
Treat process-level failures as incident boundaries
Global rejection or exception hooks can support last-resort diagnostics and a controlled shutdown policy. They are not a substitute for local ownership and should not resume ordinary service after an unknown invariant failure. Avoid immediate exit before bounded cleanup, and do not attempt elaborate asynchronous recovery from an uncaught exception without a defined policy. Use an external supervisor for restart and reconcile any uncertain side effects. A timed-out or interrupted remote write may already have succeeded, so retry requires idempotency or an explicit status check.
Verify the fix
Run the complete examples and confirm exact output and natural exit without rejection warnings. In the application test, make the dependency reject, assert the documented error outcome, and verify cleanup occurred once. Also test success and cancellation. Confirm no response or success marker is emitted before the owned operation actually completes.
Keep it from recurring
- Require every asynchronous call to have an owner, use bounded task groups, and keep failure contracts explicit. Add regression tests for detached work and review callback-based APIs before giving them async functions. Keep process-level termination policy documented and separate from request-level recovery.