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

2. Composing signals — AbortSignal.timeout() and AbortSignal.any()

The two static factories are what make cancellation composable:

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:

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:

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

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:

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

Anti-patterns

Troubleshooting

References