Async Error-Handling Anti-Patterns — Professional Level¶
Category: Async Anti-Patterns → Error Handling — errors that fall on the floor instead of propagating. Covers (collectively): Swallowed Promise Rejection · Floating Promise · Fire-and-Forget Without Logging · Forgotten
await
Table of Contents¶
- Introduction
- Prerequisites
- The Event Loop & Microtask Queue — Where Rejections Actually Go
- Swallowed Rejection —
unhandledrejectionand the Process-Kill Policy - Floating Promise & the Memory It Pins
- Fire-and-Forget — Listener, Timer, and Task Leaks Without Logging
- Forgotten
await— The Truthy Promise and the Silent Logic Bug - Tracing Across Async Boundaries —
AsyncLocalStorageand Its Cost - Python
asyncio— Destroyed Tasks and Un-Retrieved Exceptions - Go Contrast — Errors Are Values, Goroutines Still Leak
- A Combined Worked Example — A Leak You Can Watch Grow
- Common Mistakes
- Test Yourself
- Cheat Sheet
- Summary
- Further Reading
- Related Topics
Introduction¶
Focus: what these four anti-patterns cost the runtime — the microtask queue, the heap, event-listener and timer tables, the tracing context — and how you observe a failure that, by definition, made itself invisible.
junior.md taught you to recognize the four shapes. middle.md taught you to handle errors correctly. senior.md taught you to instrument async failures at scale. This file goes one layer down — to the event loop, the garbage collector, and the observability toolchain.
The defining property of these four anti-patterns is that they are silent. A swallowed rejection produces no stack trace at the throw site. A floating promise that fails leaves no caller to blame. A fire-and-forget task that dies takes its error with it. A forgotten await returns a Promise that is truthy, so every downstream if passes and every log line looks fine — until a row is missing three hours later. None of these trip a test that only asserts the happy path, and none of them appear as a hot frame in a CPU profile. They appear as rising memory, mysterious gaps in data, and a process that exits with no explanation.
Two disciplines define this level:
- Know exactly when the runtime notices. Each runtime has a precise rule for when an unhandled rejection becomes observable — it is tied to microtask draining and GC. If you can't state that rule, you can't predict whether your handler fires before or after the process dies.
- Instrument the invisible. The cure is never "be more careful." It is a process-level handler, a linter that fails the build, a structured log on every fire-and-forget path, and a trace context that survives the
awaitboundary. You make the silent failure loud before it ships.
The mental model: a Promise is a value with a hidden second channel — the rejection.
await,.catch(), and.then(_, onRej)are the only ways to read that channel. Any code path that creates a Promise and reads neither has handed the rejection to the runtime, which has its own opinion — increasingly, "terminate the process."
Prerequisites¶
- Required: Fluent with
senior.md— you can add structured logging and a global rejection handler to a service. - Required: A working model of the JS event loop: the macrotask queue (timers, I/O,
setImmediate), the microtask queue (Promise reactions,queueMicrotask,process.nextTick), and the rule that microtasks drain completely between each macrotask. - Required: A working model of a tracing GC — reachability, what keeps an object alive, why a pending Promise with live
.thencallbacks is not collectable. - Helpful: Node CLI flags (
--unhandled-rejections,--trace-warnings,--inspect), heap-snapshot reading in Chrome DevTools, and Pythonasynciodebug mode. - Helpful: memory-leak-detection, observability-stack, error-handling-patterns skills for the measurement vocabulary.
The Event Loop & Microtask Queue — Where Rejections Actually Go¶
You cannot reason about swallowed rejections without knowing when the runtime checks for them. A rejection is not "lost" the instant it happens; it is lost when a microtask settles with no reaction attached and stays that way until GC or the next tick proves no handler is coming.
The mechanics that matter at this level:
- Microtasks drain to empty between macrotasks. A chain of
.then().then().then()all runs before the next timer or I/O callback. This is why a forgottenawaitcan let unrelated work interleave (the rejection's "handler check" happens after the current microtask batch drains). - The "unhandled" check is deferred, not immediate. When a Promise rejects with no reaction, the engine flags it but waits — you can still attach
.catch()synchronously after and the engine will firerejectionHandledinstead ofunhandledRejection. The deferral is what letsconst p = reject(); p.catch(...)on the next line be safe. - GC participates. In V8, a rejected Promise with no handler that becomes unreachable triggers the unhandled-rejection callback at GC time if it hasn't already. This is why the timing of the warning is non-deterministic — it can be tied to a GC cycle.
The professional consequence: the window between "rejection occurs" and "runtime declares it unhandled" is real and observable, but it is short and not under your control. Never design around it. The only safe rule is: attach a handler in the same synchronous turn that creates the Promise.
Swallowed Rejection — unhandledrejection and the Process-Kill Policy¶
A swallowed rejection is someAsync().then(handle) with no rejection branch, or awaited work inside a try that catches and console.errors into the void. The professional concern is the runtime policy that decides what happens next — and it has changed across Node versions in ways that have broken production deploys.
Node's unhandled-rejection policy and its version history¶
| Node version | Default behavior on unhandled rejection |
|---|---|
| ≤ 14 | warn — print a deprecation warning, keep running |
| 15+ (current default) | throw — promote to an uncaught exception → process exits with code 1 |
The mode is controlled by --unhandled-rejections=<mode>:
# Modes: throw (default ≥15) | strict | warn | warn-with-error-code | none
node --unhandled-rejections=strict app.js # also print the stack, then exit
node --unhandled-rejections=warn app.js # legacy behavior — survives, logs
node --unhandled-rejections=none app.js # silence entirely — almost always wrong
The migration from warn to throw is the single most common cause of "it ran fine on Node 14, it crash-loops on Node 18." Code that relied on rejections being swallowed now takes down the process. That is a feature: a swallowed rejection was always a bug; the runtime just stopped hiding it.
The correct global handler — log, don't swallow¶
Install a process-level handler so that a rejection that escapes every local catch is recorded with full context before the process decides to die. Do not use it to keep running — by the time unhandledRejection fires, the program is in an unknown state.
// Node — structured capture of the last line of defense.
process.on('unhandledRejection', (reason, promise) => {
logger.error({
event: 'unhandledRejection',
reason: reason instanceof Error ? { msg: reason.message, stack: reason.stack } : reason,
});
// Let the process die (default 'throw' mode will). Flush logs, then exit.
// Do NOT resume as if nothing happened.
});
process.on('uncaughtException', (err) => {
logger.fatal({ event: 'uncaughtException', stack: err.stack });
process.exit(1);
});
// Browser — the same channel, plus the chance to suppress the console error
// once you've reported it to your telemetry pipeline.
window.addEventListener('unhandledrejection', (e) => {
telemetry.captureException(e.reason, { kind: 'unhandledrejection' });
// e.preventDefault(); // only if you've truly handled/reported it
});
The subtler swallow — caught, then dropped¶
The textbook case (then with no catch) is easy to lint. The expensive case is the rejection that is caught, then discarded:
// BEFORE — the rejection is "handled," so no unhandledRejection fires,
// no linter complains, and the error is gone. This is worse than a crash:
// the system keeps serving wrong results.
try {
await chargeCard(order);
} catch (e) {
// swallowed: no log, no metric, no rethrow. The order ships unpaid.
}
// AFTER — handle means: record, classify, and decide. Swallowing is a decision
// you must justify per call site, never a default.
try {
await chargeCard(order);
} catch (e) {
logger.error({ event: 'charge_failed', orderId: order.id, err: e });
metrics.increment('charge.failure', { reason: classify(e) });
throw new PaymentError('charge failed', { cause: e }); // propagate — let the caller abort the order
}
Diagnose it: run with
--unhandled-rejections=strictin CI and staging so a single escaped rejection fails the test run loudly. For the caught-then-dropped case, no runtime flag helps — only review, acatchthat always logs/rethrows, and metrics on every error path.
Floating Promise & the Memory It Pins¶
A floating Promise is one created but neither awaited nor .then/.catch-ed nor stored. The control-flow bug (the caller continues before the work finishes) is covered at lower levels. The professional concern is memory and observability: a floating Promise is a live object with live reactions, and everything its closures capture stays reachable until it settles.
Why a pending Promise is not garbage¶
A Promise that is pending and has registered reactions is reachable from the engine's internal microtask/reaction bookkeeping and from whatever holds its callbacks. Its closure captures — the request object, the buffer, the DB row you loaded — are all retained for the Promise's entire lifetime. Spawn thousands of slow floating Promises and you have a heap that grows with in-flight work that nobody is bounding or watching.
// BEFORE — a floating promise per request, capturing the whole `req`/`res`
// closure and a large payload. Under load these pile up; if `enrich` is slow
// or hangs, the closures (and their captured buffers) are pinned in the heap.
app.post('/ingest', (req, res) => {
const payload = req.body; // possibly large
enrichAndStore(payload); // floating: not awaited, not caught
res.status(202).end(); // returns immediately
});
// If enrichAndStore rejects → unhandledRejection (process may die on Node ≥15).
// If it just hangs → `payload` is retained for as long as it hangs, times the
// number of concurrent requests. Heap climbs; no error; eventual OOM.
// AFTER — bound it, await it (or track it deliberately), and ensure it can fail
// loudly. Here we await so backpressure is natural and the closure is released
// as soon as the work settles.
app.post('/ingest', async (req, res) => {
try {
await enrichAndStore(req.body);
res.status(201).end();
} catch (e) {
logger.error({ event: 'ingest_failed', err: e });
res.status(502).end();
}
});
Proving the leak with a heap snapshot¶
You do not argue this from intuition — you watch the heap.
# Run with the inspector open, drive load, then take two heap snapshots
# in Chrome DevTools (chrome://inspect) and use "Comparison" view.
node --inspect server.js
# Or programmatically dump a snapshot under load:
node -e '
const v8 = require("v8");
const fs = require("fs");
const s = v8.getHeapSnapshot();
s.pipe(fs.createWriteStream("/tmp/heap-1.heapsnapshot"));
'
In the Comparison view, a floating-Promise leak shows as a growing count of Promise objects plus the retained closure types (Buffer, your request/context objects). The retainer path points straight back to the pending Promise — that is your evidence and your culprit.
If you genuinely want fire-and-forget, say so¶
The legitimate "I do not want to wait" case must still attach a failure handler. The void keyword documents intent for both humans and linters:
// Explicit: "I am deliberately not awaiting, but failure is still observed."
void enrichAndStore(payload).catch((e) =>
logger.error({ event: 'background_enrich_failed', err: e }));
TypeScript catches it at build time¶
The @typescript-eslint/no-floating-promises rule fails the build on any Promise-returning expression whose result is discarded — unless you explicitly void it or await it. With strict typing on, this is the single highest-leverage guard against three of the four anti-patterns in this file.
// .eslintrc — require every promise to be awaited, returned, or void-ed.
{
"rules": {
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error" // catches `if (asyncFn())`, async event handlers
}
}
Diagnose it:
no-floating-promisesat build; two-snapshot heap comparison under load to see the retained closures; in production, an in-flight-task gauge (count of un-settled background tasks) that should oscillate around a steady state, not trend upward.
Fire-and-Forget — Listener, Timer, and Task Leaks Without Logging¶
Fire-and-forget is intentional floating: you mean to start background work and not wait. The anti-pattern is doing it without logging, without a supervisor, and without cleaning up the resources it registers. Failures vanish, and the side effects — event listeners, timers, intervals — accumulate.
The listener / timer leak¶
The classic production leak is a fire-and-forget routine that registers a listener or interval on a long-lived emitter and never removes it. Each invocation adds a handler; the emitter's listener array grows without bound.
// BEFORE — every call to watch() adds a listener and a setInterval that are
// never removed. Call watch() per request and you leak one of each per request.
function watch(stream) {
stream.on('data', onData); // never removed
const t = setInterval(() => flush(), 1000); // never cleared
doBackgroundSync(); // floating, unlogged: failures vanish
}
Node will warn you about the listener side once you cross the threshold:
(node:12345) MaxListenersExceededWarning: Possible EventEmitter memory leak
detected. 11 data listeners added to [Socket]. Use emitter.setMaxListeners()
to increase limit
Run with --trace-warnings to get a stack trace for that warning, pointing at the exact stream.on('data', ...) line:
// AFTER — tie every registration to a lifecycle, log failures, and use
// AbortSignal so a single signal tears down listeners and timers together.
function watch(stream, signal) {
stream.on('data', onData, { signal }); // removed on abort
const t = setInterval(() => flush(), 1000);
signal.addEventListener('abort', () => clearInterval(t), { once: true });
doBackgroundSync().catch((e) => // failures are now LOUD
logger.error({ event: 'bg_sync_failed', err: e }));
}
// const ac = new AbortController(); watch(s, ac.signal); ... ac.abort();
Spawn under a supervisor, not into the void¶
Structured concurrency says background work should have an owner that observes its completion and its failure. Even a minimal tracker beats void fn():
// A tiny supervisor: track in-flight tasks, log every failure, expose a count
// for metrics, and allow graceful drain on shutdown.
class TaskSupervisor {
#inflight = new Set();
spawn(label, fn) {
const p = (async () => {
try { await fn(); }
catch (e) { logger.error({ event: 'task_failed', label, err: e }); }
finally { this.#inflight.delete(p); }
})();
this.#inflight.add(p);
return p;
}
get size() { return this.#inflight.size; } // export as a gauge
drain() { return Promise.allSettled([...this.#inflight]); } // call on SIGTERM
}
Now every fire-and-forget failure is logged, the in-flight count is a metric you can alert on, and shutdown can wait for outstanding work instead of dropping it.
Diagnose it:
--trace-warningsto locate theMaxListenersExceededWarningsource; heap snapshots showing growingEventEmitter/Timeoutretainers; an in-flight-task gauge that trends upward; and — the simplest tell — zero log lines from a code path you know is running and sometimes failing. Absence of logs is the symptom.
Forgotten await — The Truthy Promise and the Silent Logic Bug¶
This is the most insidious of the four because it usually produces no error at all — just wrong behavior. const user = getUser(id) makes user a Promise<User>, not a User. A Promise is an object, so it is truthy, has no .name (yields undefined), and is not equal to anything you compare it to.
Why it is silent — a value, not a throw¶
// BEFORE — three different silent bugs from one missing await.
async function handle(id) {
const user = getUser(id); // Promise<User>, not User
if (user) { /* always true: a Promise is truthy */ } // (1) guard never fails
console.log('hello', user.name); // (2) "hello undefined" — no such prop on a Promise
if (user.role === 'admin') grant(); // (3) undefined === 'admin' → false: admin check silently denies
await saveAudit(id); // meanwhile getUser may still be in flight,
// and if it rejects → unhandledRejection later
}
Nothing throws here. The guard passes, the log prints garbage, the admin check quietly fails closed (or worse, open in the inverse case), and the original Promise — never awaited — may reject after this function returns, surfacing as an unhandled rejection with a stack trace that points nowhere useful.
// AFTER — await yields the value; the type now behaves as written.
async function handle(id) {
const user = await getUser(id); // User
if (!user) throw new NotFoundError(id);
console.log('hello', user.name); // real name
if (user.role === 'admin') grant(); // real check
await saveAudit(id);
}
Wasted work and lost ordering¶
A forgotten await also breaks sequencing. Code that reads as "do A, then B" runs A and B concurrently, and B may observe state before A committed:
// BEFORE — looks sequential, runs concurrently. The read can race the write.
function transfer(from, to, amt) {
debit(from, amt); // forgotten await — fire-and-forget write
return readBalance(from); // may read the pre-debit balance
}
The work isn't wasted, but the ordering guarantee the code visually promises is gone — a class of bug no profiler catches because the CPU work is fine; only the data is wrong.
Tooling that prevents it¶
- TypeScript with
no-floating-promisesandno-misused-promises: assigningPromise<User>to a slot used asUseris often caught by type errors downstream, and the discarded-Promise case is caught directly. require-await(@typescript-eslint/require-await): flagsasyncfunctions with noawait, a frequent smell near a forgotten one.- Code review heuristic: any call to a function whose name you know is async, where the result is used synchronously on the next line, is suspect.
Diagnose it: the linter is your primary defense. At runtime,
"hello undefined"-style log lines and assertions that compare a result against a known value (expect(typeof user).toBe('object')is not enough — assert a field) catch what slips through. A forgottenawaiton a rejecting call also shows up as anunhandledRejectionwhose stack doesn't match where the value was consumed — a strong tell.
Tracing Across Async Boundaries — AsyncLocalStorage and Its Cost¶
When a request's work hops across await points, callbacks, and timers, the call stack is gone — by the time a rejection fires in a microtask, there is no synchronous frame tying it back to the request that started it. This is why async errors are so hard to attribute. The runtime gives you two tools to carry context across those hops.
async_hooks and AsyncLocalStorage¶
AsyncLocalStorage (built on async_hooks) propagates a value through every async continuation spawned within a run() scope — so a log line emitted three awaits deep still knows its requestId, and an unhandledRejection handler can fish the context out.
const { AsyncLocalStorage } = require('node:async_hooks');
const als = new AsyncLocalStorage();
app.use((req, res, next) => {
als.run({ requestId: req.id, route: req.path }, () => next());
});
// Anywhere downstream — even inside a floating .catch — context is available:
function logErr(e) {
const ctx = als.getStore() ?? {};
logger.error({ ...ctx, event: 'async_error', err: e }); // now attributable
}
The cost is real — measure it¶
AsyncLocalStorage is not free. async_hooks adds bookkeeping to every async resource creation, and historically (Node ≤ 16) the overhead was significant — double-digit percentage regressions on async-heavy microbenchmarks were common. Modern Node (using V8's Promise continuation hooks) is far cheaper, but the cost is non-zero and grows with async resource churn.
# Quantify the overhead on YOUR workload — never assume.
node --prof app.js # then: node --prof-process isolate-*.log
# Or A/B two builds (ALS on/off) under identical load with autocannon/wrk
# and compare p50/p99 latency and RSS. Trace-level hooks are the expensive part.
The discipline mirrors the structural file: enable context propagation deliberately, measure its cost on your traffic, and prefer the lightweight AsyncLocalStorage API over raw async_hooks callbacks (which are markedly more expensive). For full distributed tracing, an OpenTelemetry SDK uses these primitives under the hood — same trade-off, larger blast radius.
Diagnose it:
--prof/0xflame graphs to seeasync_hooksframes; an A/B latency test toggling ALS; RSS comparison. If the overhead is unacceptable on a hot path, scope ALS narrowly rather than wrapping the entire process.
Python asyncio — Destroyed Tasks and Un-Retrieved Exceptions¶
Python's asyncio has the same four anti-patterns with its own diagnostics. The two warnings every professional must recognize on sight:
"Task was destroyed but it is pending!"¶
This is Python's floating-Promise / fire-and-forget tell. If you create a Task with asyncio.create_task() and drop the reference, the event loop's only strong reference may be weak, and the GC can collect the Task before it finishes — or the loop closes with it still pending:
# BEFORE — create_task whose result is discarded. Two failure modes:
# (1) the Task may be GC'd mid-flight → "Task was destroyed but it is pending!"
# (2) if it raises, the exception is never retrieved → warning at GC time.
async def handler(req):
asyncio.create_task(enrich(req)) # reference dropped immediately
return 202
Task was destroyed but it is pending!
task: <Task pending name='Task-7' coro=<enrich() running at app.py:12>>
The fix is to keep a strong reference (a set) and observe completion — the asyncio analogue of the TaskSupervisor above:
# AFTER — hold a reference so the Task can't be GC'd, and log failures.
_background = set()
async def handler(req):
task = asyncio.create_task(enrich(req))
_background.add(task) # strong ref: won't be collected
task.add_done_callback(_background.discard) # release when done
task.add_done_callback(_log_if_failed) # observe failure
return 202
def _log_if_failed(task: asyncio.Task):
if not task.cancelled() and task.exception():
logging.error("background task failed", exc_info=task.exception())
Better still, use structured concurrency (asyncio.TaskGroup, Python 3.11+), which owns its children, propagates their exceptions, and cancels siblings on failure — making fire-and-forget impossible by construction:
async def handler(req):
async with asyncio.TaskGroup() as tg: # awaits all children; raises on any failure
tg.create_task(enrich(req))
tg.create_task(audit(req))
"Task exception was never retrieved"¶
The swallowed-rejection analogue: a Task raised, nobody called .result() / .exception() / awaited it, and at collection time asyncio warns:
Task exception was never retrieved
future: <Task finished coro=<enrich()> exception=ValueError('bad')>
Turn on debug mode in development and CI¶
asyncio ships a debug mode that surfaces these aggressively — slow callbacks, coroutines never awaited, tasks destroyed pending:
PYTHONASYNCIODEBUG=1 python app.py # environment-driven; also logs slow callbacks (>100ms)
python -W error::RuntimeWarning app.py # promote "coroutine was never awaited" to a hard error
The forgotten-await analogue in Python is even louder than JS: calling coro() without await returns a coroutine object that never runs, and Python emits RuntimeWarning: coroutine 'foo' was never awaited. Promote it to an error in CI with -W error::RuntimeWarning.
Diagnose it:
PYTHONASYNCIODEBUG=1andasyncio.run(debug=True)in dev/CI;-W errorto fail on never-awaited coroutines; in production, theloop.set_exception_handler()callback is the asyncio analogue ofprocess.on('unhandledRejection')— install it to capture and log un-retrieved task exceptions with context.
Go Contrast — Errors Are Values, Goroutines Still Leak¶
Go has no Promises and no await, so two of these anti-patterns can't exist in the same form — and one becomes a compile-time concern of a different kind.
- Forgotten
await— impossible. There is no Promise to forget. A function that does work returns(T, error)synchronously, or you start a goroutine explicitly. There is no truthy-Promise trap. - Swallowed rejection → ignored
error. The Go analogue isresult, _ := doThing()— discarding the returnederrorwith_. The compiler doesn't force you to check it, buterrcheck/golangci-lintdoes, and it's the single most enforced lint in Go codebases. - Floating Promise / fire-and-forget → leaked goroutine. This one is alive and dangerous.
go doWork()with no way to observe its completion or its panic is exactly fire-and-forget — and a panic in a goroutine with norecovercrashes the whole process, the inverse of a swallowed JS rejection.
// BEFORE — fire-and-forget goroutine: a panic here takes down the process,
// and a blocked send leaks the goroutine forever (it never exits).
go func() {
ch <- enrich(req) // if nobody ever reads ch, this goroutine leaks
}()
// AFTER — context for cancellation, recover for the panic, log the failure.
go func() {
defer func() {
if r := recover(); r != nil {
log.Error("background panic", "recover", r, "stack", debug.Stack())
}
}()
select {
case ch <- enrich(req):
case <-ctx.Done(): // bounded: exits when the request is cancelled
}
}()
Go's tooling for the leak: the go test -race detector finds the data races that fire-and-forget goroutines invite, and runtime.NumGoroutine() (exported as a metric) is the direct analogue of the in-flight-task gauge — a count that trends upward is a goroutine leak, the same shape as a climbing Promise count in V8.
The contrast in one line: Go pushes errors into the value channel (visible, lint-enforced) and panics into the crash channel (loud), so the JS "silent rejection" mode largely disappears — but the resource leak (goroutine vs pending Promise) is the same problem with a different name and a different counter.
A Combined Worked Example — A Leak You Can Watch Grow¶
The four anti-patterns compound. Here is a single endpoint that commits all of them, followed by the instrumented fix and the evidence each step produces.
Before — every sin, every silent failure:
// A God-endpoint of async error handling: forgotten await, floating promise,
// fire-and-forget with no logging, and a swallowed rejection.
app.post('/order', (req, res) => {
const order = createOrder(req.body); // (1) FORGOTTEN await → order is a Promise
notifyWarehouse(order); // (2) FLOATING promise (and order is wrong type)
setInterval(() => syncInventory(), 5000); // (3) FIRE-AND-FORGET timer, per request, never cleared,
// failures unlogged → leaks + invisible errors
chargeCard(order).then(() => {}, () => {}); // (4) SWALLOWED rejection: empty onRejected
res.status(201).json({ id: order.id }); // order.id is undefined (it's a Promise)
});
Symptoms in production: response bodies with "id": null; RSS climbing linearly with request count (a Timeout and its closure leaked per request); MaxListenersExceededWarning in the logs; occasional unhandledRejection from notifyWarehouse with a stack that points at no business logic; and zero error logs despite charges silently failing.
After — structure and instrumentation fixed together:
const ac = new AbortController(); // one shared lifecycle for timers
const interval = setInterval(() => {
syncInventory().catch((e) => logger.error({ event: 'inventory_sync_failed', err: e }));
}, 5000);
process.on('SIGTERM', () => { clearInterval(interval); ac.abort(); }); // not per-request!
app.post('/order', async (req, res) => {
try {
const order = await createOrder(req.body); // (1) awaited → real Order
await chargeCard(order); // (4) awaited inside try → failures propagate
// (2)/(3) deliberate background work, observed and logged:
supervisor.spawn('notify_warehouse', () => notifyWarehouse(order));
res.status(201).json({ id: order.id }); // real id
} catch (e) {
logger.error({ event: 'order_failed', err: e, ...als.getStore() }); // attributable via ALS
metrics.increment('order.failure', { reason: classify(e) });
res.status(502).json({ error: 'order_failed' });
}
});
The evidence trail that proves each fix:
| Fix | Tool that confirms it |
|---|---|
Forgotten await removed | no-floating-promises lint passes; response id is non-null in a contract test |
| Floating/fire-and-forget bounded | Two-snapshot heap comparison: Promise/Timeout count steady, not growing; in-flight gauge oscillates |
| Timer leak removed | --trace-warnings no longer emits MaxListenersExceededWarning; RSS flat under sustained load |
| Swallowed rejection fixed | --unhandled-rejections=strict in CI passes; order.failure metric now increments on real failures |
The discipline, restated: you did not "be more careful." You made each silent failure produce a signal — a lint error, a metric, a steady heap, a non-null field — and then you watched the signal. Measure the leak before and after; never trust that the fix worked without the snapshot.
Common Mistakes¶
Professional-level mistakes — sophisticated, and therefore expensive:
- Using
process.on('unhandledRejection')to keep running. By the time it fires, your program is in an undefined state. Log with full context, then exit. Treating it as acatch-of-last-resort that resumes hides corruption. - Relying on
--unhandled-rejections=noneto "stabilize" a service. This re-hides the bug the runtime is trying to show you. The crash-loop after a Node upgrade is the symptom; the swallowed rejection is the disease — fix the call site. - Assuming a caught rejection is a handled one. An empty
catch(or one that onlyconsole.errors in dev) is a swallowed rejection that no linter flags. Everycatchmust log/metric and decide (rethrow, fallback, or justified swallow). void promisewithout.catch.voidsilences the linter but does not attach a handler — avoid-ed rejecting Promise is still an unhandled rejection. Alwaysvoid p.catch(log), never barevoid p.- Creating fire-and-forget tasks per request. A
setInterval/listener registered inside a request handler leaks one resource per request. Lifecycle-scoped resources belong to the server, not the request; request-scoped ones must be torn down with anAbortSignal. - Enabling
async_hooks/AsyncLocalStorageglobally without measuring. Context propagation has real per-async-resource cost. A/B it under load; scope it narrowly on hot paths; prefer theAsyncLocalStorageAPI over raw hook callbacks. - Dropping the Task reference in Python.
asyncio.create_task(f())without holding the returned Task invites GC mid-flight and "Task was destroyed but it is pending!". Keep a strong reference set, or useTaskGroup. - Trusting that a forgotten
awaitwill throw. It usually won't — a Promise is a truthy object. Assert fields of results in tests (not just truthiness), and letno-floating-promises+no-misused-promisescatch the rest at build time. - Shipping with the linter off in CI.
no-floating-promisesis the highest-leverage guard against three of these four anti-patterns. If it's not failing the build, the anti-patterns are not prevented — they're just unobserved.
Test Yourself¶
- In Node, exactly when does the runtime decide a Promise rejection is "unhandled," and why does that timing make
const p = Promise.reject(x); p.catch(f)on the next line safe? - Node 14 ran your service fine; Node 18 crash-loops on the same code. What changed in the default policy, and what flag would reproduce the old behavior (and why is using it the wrong fix)?
- A floating Promise that hangs (never settles) causes a memory leak even though it never errors. Explain what is retained and why, and name the tool that proves it.
- What does
void someAsyncFn()accomplish, and what does it fail to accomplish, with respect to unhandled rejections? const user = getUser(id); if (user.role === 'admin') grant();—getUseris async and theawaitis missing. Walk through why this throws no error and what it does instead.- In Python, you write
asyncio.create_task(enrich(req))in a handler and discard the result. Name the two distinct warnings this can produce and the conditions for each. - What is the runtime cost of
AsyncLocalStorage, where does it come from, and how would you decide whether to enable it on a hot path? - Go has no swallowed-rejection problem in the JS sense, yet
go doWork()can still be a fire-and-forget anti-pattern. What are the two failure modes of an unsupervised goroutine, and which counter detects the leak?
Answers
1. When a Promise rejects with **no reaction attached** and the **microtask queue finishes draining** for the current macrotask, the engine flags it as a candidate; the `unhandledRejection`/`unhandledrejection` event fires after that drain (or at GC for unreachable rejected Promises). Because the check is deferred to *after* the synchronous turn, attaching `.catch(f)` on the very next synchronous line registers a reaction before the check runs — the engine then fires `rejectionHandled` instead. 2. The default `--unhandled-rejections` mode changed from **`warn`** (≤14) to **`throw`** (15+), so an unhandled rejection now becomes an uncaught exception and exits the process with code 1. `--unhandled-rejections=warn` restores the old behavior, but it only re-hides the bug — the rejection was always unhandled; fix the call site (add `.catch`/`try`). 3. A pending Promise with registered reactions is reachable from the engine's reaction bookkeeping and from whatever holds its callbacks; its **closure captures** (request objects, buffers, rows) stay reachable for the Promise's whole lifetime. A hung Promise never settles, so those captures are pinned indefinitely — times the concurrency. Prove it with a **two-snapshot heap comparison** in DevTools (or `v8.getHeapSnapshot()`): a growing `Promise` count with retainer paths to your closure types. 4. `void p` documents "deliberately not awaiting" and **silences `no-floating-promises`**. It does **not** attach a rejection handler — a `void`-ed rejecting Promise is still an unhandled rejection. The correct form is `void p.catch(logFn)`. 5. `getUser(id)` returns a `PromiseCheat Sheet¶
| Anti-pattern | Runtime / observability cost | Measure / catch with | Fix |
|---|---|---|---|
| Swallowed Rejection | No stack at throw site; on Node ≥15 default, an escaped rejection kills the process; caught-then-dropped corrupts state silently | --unhandled-rejections=strict in CI; process.on('unhandledRejection'); metrics on every catch | await in try/catch that logs and decides (rethrow/fallback); global handler that logs then exits |
| Floating Promise | Pending Promise pins its closure captures → heap grows with in-flight work; rejection becomes unhandled | no-floating-promises lint; two-snapshot heap comparison; in-flight gauge | await, or void p.catch(log); bound concurrency |
| Fire-and-Forget (no logging) | Listener/timer/task leaks per call; failures invisible (no logs) | --trace-warnings (MaxListenersExceededWarning); heap retainers; absence of logs | Supervisor/TaskGroup that logs failures; AbortSignal lifecycle; server-scoped not request-scoped timers |
Forgotten await | Truthy Promise → silent wrong logic; broken ordering; late unhandled rejection | no-floating-promises + no-misused-promises; require-await; assert result fields | await the call; let types enforce Promise<T> ≠ T |
| Python analogues | "Task destroyed but pending", "exception never retrieved", "coroutine never awaited" | PYTHONASYNCIODEBUG=1, asyncio.run(debug=True), -W error::RuntimeWarning; loop.set_exception_handler | Strong-ref Task set + done-callback; asyncio.TaskGroup |
| Go analogue | Ignored error; leaked/panicking goroutine crashes process | errcheck/golangci-lint; go test -race; runtime.NumGoroutine() gauge | Check the error; context cancellation + recover in goroutines |
Three golden rules: - Attach a rejection handler in the same synchronous turn you create the Promise — the runtime's "unhandled" window is real but not yours to design around. - The cure is a signal, never "be more careful": a failing lint, a metric on every error path, a steady heap, a non-null field. Make the silent failure loud, then watch it. - Run --unhandled-rejections=strict / PYTHONASYNCIODEBUG=1 in CI so an escaped async error fails the build, not production.
Summary¶
- These four anti-patterns share one property — silence. They produce no stack at the throw site, no failed happy-path test, and no hot frame in a profiler; they surface as rising memory, missing data, and unexplained process exits.
- Swallowed Rejection: know the event-loop rule for when a rejection is declared unhandled (after microtask drain / at GC) and Node's
warn→throwpolicy shift at v15 — the cause of post-upgrade crash-loops. Install a global handler that logs then exits; never resume. The expensive variant is the caught-then-dropped rejection no linter sees. - Floating Promise: a pending Promise pins its closure captures, so unbounded floating work is a heap leak; prove it with a two-snapshot heap comparison and prevent it with
no-floating-promises.void psilences the linter but still needs.catch. - Fire-and-Forget: intentional background work without logging or lifecycle leaks listeners and timers (per-request
setIntervalis the classic) and hides failures;--trace-warningslocates theMaxListenersExceededWarning, a supervisor/TaskGroupmakes failures loud, andAbortSignalties teardown to a lifecycle. - Forgotten
await: aPromiseis truthy, so a missingawaityields silent wrong logic (guards that never fail,undefinedfield reads, broken ordering) rather than a throw; types andno-floating-promises/no-misused-promisesare the primary defense. - Python
asyncio: recognize "Task was destroyed but it is pending!", "Task exception was never retrieved", and "coroutine was never awaited" on sight; turn onPYTHONASYNCIODEBUG=1/debug=True/-W errorin CI; hold strong Task references or useTaskGroup. - Go contrast: errors are values (lint-enforced via
errcheck) and panics crash loudly, so the JS silent-rejection mode mostly vanishes — but the goroutine leak is the same resource problem, detected byruntime.NumGoroutine(). - Tracing:
AsyncLocalStoragecarries context acrossawaitboundaries so async errors become attributable — at a real, measurable cost you must A/B before enabling on a hot path. - This completes the level ladder for Async Error Handling:
junior.md(recognize) →middle.md(handle) →senior.md(instrument at scale) → professional.md (event loop, memory, observability). Drill with the practice files.
Further Reading¶
- Node.js docs —
processevents (unhandledRejection,uncaughtException,rejectionHandled) and the--unhandled-rejectionsCLI flag history. - Node.js docs —
async_hooksandAsyncLocalStorage— the API, the performance notes, and the migration away from raw hooks. - V8 blog — "Faster async functions and promises" — microtask scheduling, why
awaitis a microtask hop, and continuation-hook cost. - You Don't Know JS: Async & Performance — Kyle Simpson — the event loop, microtask vs macrotask, Promise mechanics.
- Python docs —
asyncioDeveloper Mode andloop.set_exception_handler— the debug warnings and the global exception channel. - PEP 654 /
asyncio.TaskGroup(Python 3.11) — structured concurrency and exception groups that make fire-and-forget impossible. - Notes on structured concurrency, or: Go statement considered harmful — Nathaniel J. Smith (2018) — the argument that fire-and-forget is a language-level anti-pattern.
- Effective Go — Concurrency and
go test -racedocs — goroutine leaks, panic propagation, the race detector.
Related Topics¶
- Execution Shape →
awaitin a Loop — the sibling category; accidental serialization and the microtask scheduling cost ofawait. - Concurrency Anti-Patterns — the multi-thread sibling chapter; shared-memory races vs the cooperative-multitasking failures here.
- Refactoring → Error Handling — the smell-level treatment of swallowed errors.
- Clean Code → Error Handling — propagate-don't-swallow as a source-level discipline.
- memory-leak-detection · observability-stack · error-handling-patterns — the heap-snapshot, tracing, and error-design toolkits referenced throughout.
- Backend → Distributed Systems — fire-and-forget, retry, and timeout at the network layer.
In this topic