Node.js Async Control-Flow, Errors & Context Propagation
Parent: JavaScript and Node.js · researched 2026-06-02T18:03:41.863Z· 8 sources · 7 concepts · skill nodejs-async-patterns-error-context
This reference is the layer above basic promises. Once you can await, three problems
Overview
- This reference is the layer above basic promises. Once you can await, three problems [source]
- remain: how do you cancel an in-flight operation, how do you carry **structured error [source]
- information (not just a string), and how do you keep per-request context** (a request [source]
- id, a trace span, a tenant) alive as control hops across await points, timers, and [source]
- callbacks. Cancellation, structured errors, and async context are what this file covers. [source]
- It defers three adjacent topics: the intro layer (what a promise is, async/await, [source]
- .then/.catch) → javascript-nodejs; the libuv event-loop phase model, [source]
- microtask-vs-macrotask ordering, and process.nextTick starvation → nodejs-concurrency-internals; [source]
- and diagnostics_channel / channel.bindStore() / TracingChannel for APM-style [source]
- instrumentation → nodejs-diagnostics-profiling (this file uses AsyncLocalStorage [source]
- for your own request context). [source]
- The mental model: a unit of async work should be cancellable (carries an AbortSignal), [source]
- should fail with a typed, chainable error (carries code + cause), and should run [source]
- inside a context (AsyncLocalStorage) any nested async call can read without threading [source]
- an argument through every function. [source]
1. Cancellation — `AbortController` / `AbortSignal`
- AbortController is the standard cancellation primitive (Web-platform, available globally [source]
- in Node - no import). A controller owns one signal; calling controller.abort(reason) [source]
- flips signal.aborted to true, records signal.reason, and fires the 'abort' event. [source]
- The signal is the cancellation token. Pass signal into the options of any [source]
- abort-aware API: fetch(url, { signal }), node:timers/promises [source]
- (setTimeout(ms, value, { signal })), fs.readFile(path, { signal }), [source]
- events.once(emitter, name, { signal }), and stream/pipeline operations. The API [source]
- rejects (or rejects the awaited promise) with an AbortError (err.code === 'ABORT_ERR') [source]
- — or with your custom reason if you passed one to abort(). [source]
- signal.reason is whatever you passed to abort(reason); if you passed nothing it [source]
- defaults to a DOMException named AbortError. signal.throwIfAborted() throws that [source]
- reason immediately - call it at the top of and between steps in a long async function so a [source]
- late-arriving cancellation short-circuits. [source]
- 'abort' event: register a listener (with { once: true }) to run teardown that the [source]
- awaited API can't do for you (close a file you opened, roll back). [source]
2. Composing signals — `AbortSignal.timeout()` and `AbortSignal.any()`
- The two static factories are what make cancellation composable: [source]
- AbortSignal.timeout(ms) returns a signal that auto-aborts after ms (with a [source]
- TimeoutError reason). It does not keep the event loop alive - it won't, by itself, [source]
- prevent the process from exiting. [source]
- AbortSignal.any([...signals]) returns a signal that aborts as soon as any input [source]
- aborts, adopting that signal's reason. This is how you OR together a request-deadline [source]
- AbortSignal.abort(reason) returns an already-aborted signal - handy for tests or for [source]
- passing "already cancelled" into a function uniformly. [source]
3. Structured errors — `Error.cause`, `AggregateError`, custom classes
- A thrown string loses information. Node + modern JS give you three structuring tools: [source]
- Error.cause - the second-argument options bag: new Error('msg', { cause }). It [source]
- chains a low-level failure to a higher-level one without flattening the message, and [source]
- util.inspect/stack printing walks the chain. Re-throw with context, keep the original: [source]
- AggregateError - holds multiple errors in err.errors (an array). This is exactly [source]
- what Promise.any rejects with when every input rejects, and the right type to throw [source]
- when you've collected several failures (see Promise.allSettled below). [source]
- Custom error classes - subclass Error, set a stable code, and (optionally) carry [source]
- cause. Always set name and code: [source]
4. The `error.code` convention — match on code, never message
- Node attaches a stable string code to its errors (ERR_INVALID_ARG_TYPE, [source]
- ABORT_ERR, ENOENT, ECONNREFUSED, …). The docs are explicit: error.code changes [source]
- only across major Node versions, while error.message may change in any version. [source]
- Branch on code, not on the message - message matching is a latent bug that breaks on [source]
- upgrade and across locales. [source]
5. Process-level failure — rejections, exceptions, and exit semantics
- Two process events are the safety net of last resort: [source]
- 'unhandledRejection' (reason, promise) - a Promise rejected with no handler [source]
- attached within a turn of the loop. 'rejectionHandled' (promise) fires if a [source]
- handler is attached later - track a Map keyed by the promise to reconcile the two [source]
- (add on unhandledRejection, delete on rejectionHandled) and report only the survivors. [source]
- 'uncaughtException' (err, origin) - an exception bubbled to the loop with no [source]
- try/catch. 'uncaughtExceptionMonitor' observes it without changing the [source]
- crash-or-not behavior (use it to log to an APM, then let the normal handling run). [source]
- Why uncaughtException must generally be fatal. The docs state it plainly: an uncaught [source]
- exception means the app is in an undefined state; 'uncaughtException' is not an [source]
- On Error Resume Next. The correct use is synchronous cleanup of resources (flush a [source]
- log, release file descriptors) and then exit - let an external supervisor restart the [source]
- process. Resuming after it is unsafe. [source]
- Exit semantics. Prefer setting process.exitCode = n and letting the loop drain [source]
- naturally over process.exit(n), which terminates synchronously and can truncate [source]
- buffered stdout/stderr. The 'exit' event handler may run synchronous code only [source]
- — queued async work is abandoned the instant it returns. [source]
- --unhandled-rejections=<mode> controls rejection handling: throw (the default since [source]
- Node 15 - emit the event, else raise as an uncaught exception), strict (always raise as [source]
- uncaught), warn (always warn, never throw), warn-with-error-code (warn and set a nonzero [source]
- exit code), and none (silence entirely). [source]
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: [source]
- The teaching point: all is fail-fast (one rejection abandons the others' results), [source]
- allSettled is collect-all (you get every outcome, success and failure, and inspect [source]
- status). Use all when any failure should abort the batch; use allSettled for "do all [source]
- of these, then tell me what worked." race settles on the first outcome of either kind; [source]
- any ignores rejections until a fulfilment (or gives you an AggregateError). [source]
- Concurrency limiting. Promise.all(items.map(fn)) fires all tasks at once - fine for [source]
- 10, a thundering herd for 10,000 (socket exhaustion, rate-limit bans). Cap in-flight work [source]
- with a pool (a small worker-count loop pulling from a shared iterator, or a library like [source]
- p-limit). This is the practical complement to the combinators. [source]
7. Context propagation — `AsyncLocalStorage` (+ `async_hooks`/`AsyncResource`)
- AsyncLocalStorage (from node:async_hooks) carries a value through an async call [source]
- chain without threading it as a parameter - the canonical use being a per-request id or [source]
- trace context that any nested function can read: [source]
- run(store, cb, ...args) is the API you want 95% of the time: it scopes store to [source]
- cb and every async operation spawned inside it, then restores the previous store. Nested [source]
- run calls shadow cleanly. [source]
- getStore() returns the current store (or undefined outside any run). [source]
- enterWith(store) sets the store for the rest of the current synchronous execution [source]
- and onward - with no automatic exit. The docs warn this leaks easily (e.g. a second [source]
- event-handler on the same emitter inherits it). **Prefer run(); reach for enterWith() [source]
- only with a strong reason.** exit(cb) runs cb outside the store; disable() tears [source]
- AsyncLocalStorage.snapshot() captures the current context and returns a function [source]
- that re-enters it later - useful for re-binding a callback to the context it was created [source]
- in without a full AsyncResource. AsyncLocalStorage.bind(fn) wraps a function so it [source]
- always runs in the captured context. [source]
- Performance / when to use. It is the recommended, stable, optimized mechanism - far [source]
- better than rolling your own with async_hooks. It is not free (context tracking has a [source]
- measured cost), so use it for genuinely cross-cutting state (request id, trace, tenant), [source]
- not as a general-purpose variable bag. [source]
- When the context is lost, the propagation broke at a boundary AsyncLocalStorage can't [source]
- see - a callback queued by native/3rd-party code, an object-pool worker, or a long-lived [source]
- emitter. That is the job of async_hooks and AsyncResource, the low-level layer: [source]
- async_hooks.createHook({ init, before, after, destroy }) registers lifecycle [source]
- callbacks for every async resource; executionAsyncId() / triggerAsyncId() expose the [source]
- current resource and the one that scheduled it. This is the machinery AsyncLocalStorage [source]
- AsyncResource is the piece you actually use directly: wrap a callback that will be [source]
- invoked later (from a connection pool, a cache, a custom emitter) so it runs in the [source]
- correct context. Construct new AsyncResource('MyThing') and call [source]
- resource.runInAsyncScope(cb, thisArg, ...args), or wrap once with the static [source]
- AsyncResource.bind(fn). This is how you re-attach context across a pool boundary. [source]
- Caveat: async_hooks is Stability 1 (experimental) and low-level; the docs [source]
- explicitly discourage using the hook API directly and steer you to AsyncLocalStorage [source]
- for context tracking. Use AsyncResource.bind for the pool-callback case; avoid building [source]
- context systems on raw createHook. [source]
Practical patterns
- util.promisify(fn) converts an error-first callback function (…, (err, value) => …) [source]
- into a promise-returning one; util.callbackify(fn) does the reverse for an async [source]
- function. If a function ships a better promise form, it advertises it on the [source]
- util.promisify.custom symbol and promisify returns that instead of wrapping the [source]
- callback. (Most core modules already expose node:fs/promises etc. - promisify is for [source]
- third-party or legacy callback APIs.) [source]
- Thread one signal through a whole operation. Accept { signal } in your own async [source]
- functions, call signal.throwIfAborted() between steps, and forward the same signal to [source]
- every downstream call (fetch, timers, fs) so one abort() unwinds the entire tree. [source]
- Time-box with AbortSignal.any([userSignal, AbortSignal.timeout(ms)]) instead of [source]
- racing a manual setTimeout reject - composition is cleaner and you keep the abort reason. [source]
- Reconcile rejections with the Map pattern: add on 'unhandledRejection', delete on [source]
- 'rejectionHandled', report the residue on shutdown. [source]
- Re-bind pool callbacks with AsyncResource.bind(cb) (or AsyncLocalStorage.bind) at [source]
- enqueue time so the dequeued callback runs in the right request context. [source]
Anti-patterns
- Serialized awaits in a loop - for (const x of xs) await f(x) when the calls are [source]
- independent. That's sequential latency; use await Promise.all(xs.map(f)) (with a [source]
- concurrency cap for large xs). [source]
- Unbounded Promise.all over a huge array - fires every task at once and exhausts [source]
- sockets / trips rate limits. Cap in-flight work. [source]
- Treating 'uncaughtException' as resume-and-continue - the process is in an undefined [source]
- state; do sync cleanup, then exit and let a supervisor restart. [source]
- process.exit() to "finish" - truncates buffered output and abandons pending work; [source]
- set process.exitCode and let the loop drain. [source]
- Matching on err.message - breaks across versions and locales; match on err.code. [source]
- Swallowing errors (catch {}), or catching without re-throwing the unknown ones - you [source]
- lose the failure and the cause chain. Narrow on code, re-throw the rest. [source]
- AsyncLocalStorage.enterWith() in shared/event-handler code - leaks the store into [source]
- unrelated later callbacks; use run(). [source]
- Building a context system on raw async_hooks.createHook - it's experimental and [source]
- error-prone (a throw in a hook is fatal); use AsyncLocalStorage / AsyncResource. [source]
Troubleshooting
- fetch/op doesn't actually stop on abort → you logged 'abort' but didn't pass [source]
- signal into the call's options, or you created a new controller per retry; pass the [source]
- same signal down and check signal.aborted. [source]
- Caught error but instanceof MyError is false → you compared types across a module [source]
- boundary or the error was re-wrapped; branch on err.code (and inspect err.cause) instead. [source]
- Promise.any "fails" unexpectedly → it rejects with an AggregateError only when [source]
- all inputs reject; read err.errors. If you wanted first-settled, use race. [source]
- Process crashes despite an uncaughtExceptionMonitor listener → monitor does not [source]
- suppress the crash; install an 'uncaughtException' handler (that then exits) if you must [source]
- intercept, but don't resume. [source]
- als.getStore() is undefined downstream → context was lost at a native/pool/emitter [source]
- boundary; wrap the callback with AsyncResource.bind / AsyncLocalStorage.bind, or [source]
- promisify a callback API so the store propagates. Don't paper over it with enterWith(). [source]
- Unhandled rejection silently ignored → check --unhandled-rejections; if set to [source]
- warn/none it won't crash. Default (throw) surfaces it. [source]
References
- Node.js - Globals: AbortController / AbortSignal (abort, reason, throwIfAborted, timeout(), any()): https://nodejs.org/api/globals.html [source]
- Node.js - Errors: Error.cause, error.code convention (match code not message), AggregateError, ABORT_ERR/system codes: https://nodejs.org/api/errors.html [source]
- Node.js - Process: uncaughtException / unhandledRejection / rejectionHandled / uncaughtExceptionMonitor, process.exitCode vs process.exit(), 'exit': https://nodejs.org/api/process.html [source]
- Node.js - CLI: --unhandled-rejections=<throw|strict|warn|warn-with-error-code|none>: https://nodejs.org/api/cli.html [source]
- Node.js - util.promisify / util.callbackify / util.promisify.custom: https://nodejs.org/api/util.html [source]
- Node.js - Async context tracking: AsyncLocalStorage (run/getStore/enterWith/exit/snapshot/bind) and AsyncResource: https://nodejs.org/api/async_context.html [source]
- Node.js - async_hooks (createHook init/before/after/destroy, executionAsyncId/triggerAsyncId; experimental, prefer AsyncLocalStorage): https://nodejs.org/api/async_hooks.html [source]
- MDN - Promise.all / Promise.allSettled / Promise.any (AggregateError) / Promise.race: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise [source]
Children
- Cancellation with AbortController / AbortSignal (signal, reason, 'abort', throwIfAborted) (frontier)
- Composing signals — AbortSignal.timeout() and AbortSignal.any() (frontier)
- Structured errors — Error.cause, AggregateError, custom error classes (frontier)
- The error.code convention — match on code, never message (frontier)
- Process-level failure — unhandledRejection / uncaughtException / rejectionHandled and exit semantics (frontier)
- Promise concurrency — all vs allSettled vs any vs race + concurrency limiting (frontier)
- Context propagation — AsyncLocalStorage (+ async_hooks / AsyncResource) (frontier)
Frontier under this node: Cancellation with AbortController / AbortSignal (signal, reason, 'abort', throwIfAborted), Composing signals — AbortSignal.timeout() and AbortSignal.any(), Context propagation — AsyncLocalStorage (+ async_hooks / AsyncResource), Process-level failure — unhandledRejection / uncaughtException / rejectionHandled and exit semantics, Promise concurrency — all vs allSettled vs any vs race + concurrency limiting, Structured errors — Error.cause, AggregateError, custom error classes, The error.code convention — match on code, never message