Node.js Async Control-Flow, Errors & Context Propagation
Node.js Async Control-Flow — Cancellation, Structured Errors & Context
Overview
This reference is the layer above basic promises. Once you can await, three problems
remain: how do you cancel an in-flight operation, how do you carry structured error
information (not just a string), and how do you keep per-request context (a request
id, a trace span, a tenant) alive as control hops across await points, timers, and
callbacks. Cancellation, structured errors, and async context are what this file covers.
It defers three adjacent topics: the intro layer (what a promise is, async/await,
.then/.catch) → javascript-nodejs; the libuv event-loop phase model,
microtask-vs-macrotask ordering, and process.nextTick starvation → nodejs-concurrency-internals;
and diagnostics_channel / channel.bindStore() / TracingChannel for APM-style
instrumentation → nodejs-diagnostics-profiling (this file uses AsyncLocalStorage
for your own request context).
The mental model: a unit of async work should be cancellable (carries an AbortSignal),
should fail with a typed, chainable error (carries code + cause), and should run
inside a context (AsyncLocalStorage) any nested async call can read without threading
an argument through every function.
Core concepts
1. Cancellation — AbortController / AbortSignal
AbortController is the standard cancellation primitive (Web-platform, available globally
in Node — no import). A controller owns one signal; calling controller.abort(reason)
flips signal.aborted to true, records signal.reason, and fires the 'abort' event.
const ac = new AbortController();
ac.signal.addEventListener('abort', () => console.log('cancelled:', ac.signal.reason),
{ once: true }); // { once: true } avoids a leak
setTimeout(() => ac.abort(new Error('user navigated away')), 5_000);
await fetch(url, { signal: ac.signal }); // rejects with the reason on abort
- The signal is the cancellation token. Pass
signalinto the options of any abort-aware API:fetch(url, { signal }),node:timers/promises(setTimeout(ms, value, { signal })),fs.readFile(path, { signal }),events.once(emitter, name, { signal }), and stream/pipeline operations. The API rejects (or rejects the awaited promise) with anAbortError(err.code === 'ABORT_ERR') — or with your customreasonif you passed one toabort(). signal.reasonis whatever you passed toabort(reason); if you passed nothing it defaults to aDOMExceptionnamedAbortError.signal.throwIfAborted()throws that reason immediately — call it at the top of and between steps in a long async function so a late-arriving cancellation short-circuits.'abort'event: register a listener (with{ once: true }) to run teardown that the awaited API can’t do for you (close a file you opened, roll back).
2. Composing signals — AbortSignal.timeout() and AbortSignal.any()
The two static factories are what make cancellation composable:
AbortSignal.timeout(ms)returns a signal that auto-aborts afterms(with aTimeoutErrorreason). It does not keep the event loop alive — it won’t, by itself, prevent the process from exiting.AbortSignal.any([...signals])returns a signal that aborts as soon as any input aborts, adopting that signal’sreason. This is how you OR together a request-deadline and a user-cancel:
import { setTimeout as delay } from 'node:timers/promises';
const userCancel = new AbortController();
const signal = AbortSignal.any([userCancel.signal, AbortSignal.timeout(10_000)]);
await fetch(url, { signal }); // aborts on whichever fires first; reason tells you which
AbortSignal.abort(reason) returns an already-aborted signal — handy for tests or for
passing “already cancelled” into a function uniformly.
3. Structured errors — Error.cause, AggregateError, custom classes
A thrown string loses information. Node + modern JS give you three structuring tools:
-
Error.cause— the second-argument options bag:new Error('msg', { cause }). It chains a low-level failure to a higher-level one without flattening the message, andutil.inspect/stack printing walks the chain. Re-throw with context, keep the original:try { await db.query(sql); } catch (cause) { throw new Error('failed to load user profile', { cause }); } -
AggregateError— holds multiple errors inerr.errors(an array). This is exactly whatPromise.anyrejects with when every input rejects, and the right type to throw when you’ve collected several failures (seePromise.allSettledbelow). -
Custom error classes — subclass
Error, set a stablecode, and (optionally) carrycause. Always setnameandcode:class ConfigError extends Error { constructor(message, opts) { super(message, opts); this.name = 'ConfigError'; this.code = 'E_CONFIG'; } }
4. The error.code convention — match on code, never message
Node attaches a stable string code to its errors (ERR_INVALID_ARG_TYPE,
ABORT_ERR, ENOENT, ECONNREFUSED, …). The docs are explicit: error.code changes
only across major Node versions, while error.message may change in any version.
Branch on code, not on the message — message matching is a latent bug that breaks on
upgrade and across locales.
try { await fs.readFile(p); }
catch (err) {
if (err.code === 'ENOENT') return null; // stable
if (err.code === 'ABORT_ERR') return; // cancelled, not an error
throw err; // unknown — re-throw, don't swallow
}
5. Process-level failure — rejections, exceptions, and exit semantics
Two process events are the safety net of last resort:
'unhandledRejection'(reason, promise)— aPromiserejected with no handler attached within a turn of the loop.'rejectionHandled'(promise)fires if a handler is attached later — track aMapkeyed by the promise to reconcile the two (add onunhandledRejection, delete onrejectionHandled) and report only the survivors.'uncaughtException'(err, origin)— an exception bubbled to the loop with notry/catch.'uncaughtExceptionMonitor'observes it without changing the crash-or-not behavior (use it to log to an APM, then let the normal handling run).
Why uncaughtException must generally be fatal. The docs state it plainly: an uncaught
exception means the app is in an undefined state; 'uncaughtException' is not an
On Error Resume Next. The correct use is synchronous cleanup of resources (flush a
log, release file descriptors) and then exit — let an external supervisor restart the
process. Resuming after it is unsafe.
Exit semantics. Prefer setting process.exitCode = n and letting the loop drain
naturally over process.exit(n), which terminates synchronously and can truncate
buffered stdout/stderr. The 'exit' event handler may run synchronous code only
— queued async work is abandoned the instant it returns.
--unhandled-rejections=<mode> controls rejection handling: throw (the default since
Node 15 — emit the event, else raise as an uncaught exception), strict (always raise as
uncaught), warn (always warn, never throw), warn-with-error-code (warn and set a nonzero
exit code), and none (silence entirely).
6. Promise concurrency — all vs allSettled vs any vs race
The four combinators differ on fail-fast vs collect-all and on fulfillment vs first-settled:
| Combinator | Settles when… | Fulfills with | Rejects with |
|---|---|---|---|
Promise.all |
all fulfil, or first rejects (fail-fast) | array of values | the first rejection reason |
Promise.allSettled |
all settle (never rejects) | array of {status:'fulfilled',value} / {status:'rejected',reason} |
— (does not reject) |
Promise.any |
first fulfils, or all reject | first fulfilment value | AggregateError (all reasons) |
Promise.race |
first settles (fulfil or reject) | first settled value | first settled reason |
The teaching point: all is fail-fast (one rejection abandons the others’ results),
allSettled is collect-all (you get every outcome, success and failure, and inspect
status). Use all when any failure should abort the batch; use allSettled for “do all
of these, then tell me what worked.” race settles on the first outcome of either kind;
any ignores rejections until a fulfilment (or gives you an AggregateError).
Concurrency limiting. Promise.all(items.map(fn)) fires all tasks at once — fine for
10, a thundering herd for 10,000 (socket exhaustion, rate-limit bans). Cap in-flight work
with a pool (a small worker-count loop pulling from a shared iterator, or a library like
p-limit). This is the practical complement to the combinators.
7. Context propagation — AsyncLocalStorage (+ async_hooks/AsyncResource)
AsyncLocalStorage (from node:async_hooks) carries a value through an async call
chain without threading it as a parameter — the canonical use being a per-request id or
trace context that any nested function can read:
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
http.createServer((req, res) => {
als.run({ reqId: crypto.randomUUID() }, () => handle(req, res)); // store survives awaits
});
function log(msg) { console.log(als.getStore()?.reqId, msg); } // reads it anywhere downstream
run(store, cb, ...args)is the API you want 95% of the time: it scopesstoretocband every async operation spawned inside it, then restores the previous store. Nestedruncalls shadow cleanly.getStore()returns the current store (orundefinedoutside anyrun).enterWith(store)sets the store for the rest of the current synchronous execution and onward — with no automatic exit. The docs warn this leaks easily (e.g. a second event-handler on the same emitter inherits it). Preferrun(); reach forenterWith()only with a strong reason.exit(cb)runscboutside the store;disable()tears the instance down.AsyncLocalStorage.snapshot()captures the current context and returns a function that re-enters it later — useful for re-binding a callback to the context it was created in without a fullAsyncResource.AsyncLocalStorage.bind(fn)wraps a function so it always runs in the captured context.- Performance / when to use. It is the recommended, stable, optimized mechanism — far
better than rolling your own with
async_hooks. It is not free (context tracking has a measured cost), so use it for genuinely cross-cutting state (request id, trace, tenant), not as a general-purpose variable bag.
When the context is lost, the propagation broke at a boundary AsyncLocalStorage can’t
see — a callback queued by native/3rd-party code, an object-pool worker, or a long-lived
emitter. That is the job of async_hooks and AsyncResource, the low-level layer:
async_hooks.createHook({ init, before, after, destroy })registers lifecycle callbacks for every async resource;executionAsyncId()/triggerAsyncId()expose the current resource and the one that scheduled it. This is the machineryAsyncLocalStorageis built on.AsyncResourceis the piece you actually use directly: wrap a callback that will be invoked later (from a connection pool, a cache, a custom emitter) so it runs in the correct context. Constructnew AsyncResource('MyThing')and callresource.runInAsyncScope(cb, thisArg, ...args), or wrap once with the staticAsyncResource.bind(fn). This is how you re-attach context across a pool boundary.- Caveat:
async_hooksis Stability 1 (experimental) and low-level; the docs explicitly discourage using the hook API directly and steer you toAsyncLocalStoragefor context tracking. UseAsyncResource.bindfor the pool-callback case; avoid building context systems on rawcreateHook.
Key APIs
| API | Purpose | Notes |
|---|---|---|
new AbortController() / .signal / .abort(reason) |
Cancellation token + trigger | signal.aborted, signal.reason, signal.throwIfAborted(), 'abort' event |
AbortSignal.timeout(ms) / AbortSignal.any([…]) / AbortSignal.abort(r) |
Compose / time-box / pre-abort signals | any adopts the first firing signal’s reason |
new Error(msg, { cause }) |
Error chaining | err.cause; inspect walks the chain |
AggregateError (err.errors) |
Multiple failures in one error | What Promise.any rejects with |
err.code |
Stable error identity | Match on this, not err.message |
| `process.on(‘unhandledRejection’ | ‘uncaughtException’ | ‘rejectionHandled’, …)` |
process.exitCode vs process.exit(code) |
Graceful vs immediate exit | exit() can truncate stdout/stderr |
Promise.all / allSettled / any / race |
Concurrency combinators | fail-fast vs collect-all vs first-fulfil vs first-settled |
util.promisify(fn) / util.callbackify(fn) / util.promisify.custom |
Callback ⇄ promise bridge | error-first convention; custom symbol overrides |
AsyncLocalStorage run/getStore/enterWith/exit/snapshot/bind |
Request-scoped context | prefer run; enterWith leaks |
async_hooks.createHook / AsyncResource.bind / runInAsyncScope |
Low-level context plumbing | experimental; for pool/callback re-binding |
Practical patterns
util.promisify(fn)converts an error-first callback function(…, (err, value) => …)into a promise-returning one;util.callbackify(fn)does the reverse for an async function. If a function ships a better promise form, it advertises it on theutil.promisify.customsymbol andpromisifyreturns that instead of wrapping the callback. (Most core modules already exposenode:fs/promisesetc. — promisify is for third-party or legacy callback APIs.)- Thread one signal through a whole operation. Accept
{ signal }in your own async functions, callsignal.throwIfAborted()between steps, and forward the same signal to every downstream call (fetch, timers, fs) so oneabort()unwinds the entire tree. - Time-box with
AbortSignal.any([userSignal, AbortSignal.timeout(ms)])instead of racing a manualsetTimeoutreject — composition is cleaner and you keep the abort reason. - Reconcile rejections with the
Mappattern: add on'unhandledRejection', delete on'rejectionHandled', report the residue on shutdown. - Re-bind pool callbacks with
AsyncResource.bind(cb)(orAsyncLocalStorage.bind) at enqueue time so the dequeued callback runs in the right request context.
Anti-patterns
- Serialized awaits in a loop —
for (const x of xs) await f(x)when the calls are independent. That’s sequential latency; useawait Promise.all(xs.map(f))(with a concurrency cap for largexs). - Unbounded
Promise.allover a huge array — fires every task at once and exhausts sockets / trips rate limits. Cap in-flight work. - Treating
'uncaughtException'as resume-and-continue — the process is in an undefined state; do sync cleanup, then exit and let a supervisor restart. process.exit()to “finish” — truncates buffered output and abandons pending work; setprocess.exitCodeand let the loop drain.- Matching on
err.message— breaks across versions and locales; match onerr.code. - Swallowing errors (
catch {}), or catching without re-throwing the unknown ones — you lose the failure and thecausechain. Narrow oncode, re-throw the rest. AsyncLocalStorage.enterWith()in shared/event-handler code — leaks the store into unrelated later callbacks; userun().- Building a context system on raw
async_hooks.createHook— it’s experimental and error-prone (a throw in a hook is fatal); useAsyncLocalStorage/AsyncResource.
Troubleshooting
fetch/op doesn’t actually stop on abort → you logged'abort'but didn’t passsignalinto the call’s options, or you created a new controller per retry; pass the samesignaldown and checksignal.aborted.- Caught error but
instanceof MyErroris false → you compared types across a module boundary or the error was re-wrapped; branch onerr.code(and inspecterr.cause) instead. Promise.any“fails” unexpectedly → it rejects with anAggregateErroronly when all inputs reject; readerr.errors. If you wanted first-settled, userace.- Process crashes despite an
uncaughtExceptionMonitorlistener → monitor does not suppress the crash; install an'uncaughtException'handler (that then exits) if you must intercept, but don’t resume. als.getStore()isundefineddownstream → context was lost at a native/pool/emitter boundary; wrap the callback withAsyncResource.bind/AsyncLocalStorage.bind, or promisify a callback API so the store propagates. Don’t paper over it withenterWith().- Unhandled rejection silently ignored → check
--unhandled-rejections; if set towarn/noneit won’t crash. Default (throw) surfaces it.
References
- Node.js — Globals:
AbortController/AbortSignal(abort,reason,throwIfAborted,timeout(),any()): https://nodejs.org/api/globals.html - Node.js — Errors:
Error.cause,error.codeconvention (match code not message),AggregateError,ABORT_ERR/system codes: https://nodejs.org/api/errors.html - Node.js — Process:
uncaughtException/unhandledRejection/rejectionHandled/uncaughtExceptionMonitor,process.exitCodevsprocess.exit(),'exit': https://nodejs.org/api/process.html - Node.js — CLI:
--unhandled-rejections=<throw|strict|warn|warn-with-error-code|none>: https://nodejs.org/api/cli.html - Node.js —
util.promisify/util.callbackify/util.promisify.custom: https://nodejs.org/api/util.html - Node.js — Async context tracking:
AsyncLocalStorage(run/getStore/enterWith/exit/snapshot/bind) andAsyncResource: https://nodejs.org/api/async_context.html - Node.js —
async_hooks(createHookinit/before/after/destroy,executionAsyncId/triggerAsyncId; experimental, prefer AsyncLocalStorage): https://nodejs.org/api/async_hooks.html - MDN —
Promise.all/Promise.allSettled/Promise.any(AggregateError) /Promise.race: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise