Skip to content
Knowledge Hub

AsyncLocalStorage: per-request context, when context gets lost, and when not to use it Intermediate

Estimated reading time: 8 min read

Two requests arrive almost simultaneously, both go through await and write logs. We want every log line to carry the requestId of the request that produced it.

A module-scope let currentRequestId is wrong immediately: A assigns its ID and waits on I/O, B assigns a different ID, and when A resumes the variable belongs to B.

AsyncLocalStorage (ALS) binds a store (an object holding context) to an asynchronous chain of work. run(store, fn) opens a context; getStore() reads the store of the currently running chain. ALS works only within one process; it does not propagate over HTTP or a message broker by itself.

Approach Fits when Cost
Pass parameters Business data (user, tenant, transaction), few layers Function signatures change, but it is explicit and easy to test
ALS Operational metadata read in many places: correlation ID, trace context, logger A hidden dependency on the entry point; context is lost if a boundary is missed
Scope.REQUEST in NestJS A provider really needs state that lives for exactly one request An instance per request, and the scope bubbles up the dependency tree (provider scopes)

The rule: business data goes through parameters; metadata for logs and traces goes through ALS; request scope only when there is complex state tied to the request lifecycle. The NestJS docs also present an ALS-based per-request store as a way to keep providers singleton.

Two requests interleaving in one Node process
Two requests interleaving in one Node processStep 1/4: A enters run({requestId: A}); A's async resources hold store A.Request ANode processRequest Bstore A
Request A → Node processstore A
Step 1/4

A enters run({requestId: A}); A's async resources hold store A.

Each run() opens a context. Async resources (Promises, timers, I/O) created inside it keep that context, so after await, getStore() still returns the store of the right branch. Outside run() the store is undefined. Three things to remember:

  • The store is an object reference, not a copy: every function in the request sees changes to it. Keep the store small (IDs, tenant, child logger).
  • Prefer run() over enterWith(): enterWith carries the context through the entire remaining synchronous execution and is still Experimental according to the Node docs.
  • getStore() outside any run() returns undefined, so cron jobs and consumers must open their own context at the entry point.
import { AsyncLocalStorage } from 'node:async_hooks';
type Ctx = { requestId: string };
export const als = new AsyncLocalStorage<Ctx>();
export const log = (msg: string) =>
console.log(`[${als.getStore()?.requestId ?? 'no-ctx'}] ${msg}`);

Node preserves context across Promises, setTimeout and fs callbacks automatically.

Context is lost (or becomes someone else’s) when a callback does not run in the async chain that created it. Demo context-loss-demo.ts: request A registers everything inside run(), request B emits the event and drains the queue. Actual output (Node 24.17):

EventEmitter listener sees: B
callback in hand-written queue sees: B
callback bound with AsyncLocalStorage.bind sees: A
Promise.then sees: A
setTimeout sees: A
fs.readFile callback sees: A
Case Context seen Why Fix
Shared EventEmitter listener The emitter’s context (B) Node docs: a listener may run in a different context than at on() time AsyncLocalStorage.bind(fn) or AsyncResource.bind
Hand-written queue/pool, callback-style driver with its own queueing The context of whoever invokes the callback The callback is stored and then run in a different chain AsyncLocalStorage.bind(fn) when enqueuing
Old callback-style library May be lost Does not go through Promises util.promisify (Node’s recommendation) or AsyncResource
Custom thenable (not a native Promise) May be lost Node cannot track it AsyncResource

A typical bug: a logger reads getStore() inside an order.created listener and logs the requestId of the request that triggered the event instead of the request that registered the listener. This bug is silent; nothing throws.

  • Node 24: AsyncLocalStorage runs on AsyncContextFrame by default, instead of the async_hooks-based implementation (24.0.0 release notes: “lib: make ALS default to AsyncContextFrame”). Node 24.17 still has the --no-async-context-frame flag to turn it off, according to node --help.
  • Node 20 and 22: use the async_hooks backend; from 22.7.0 you can enable AsyncContextFrame with the --experimental-async-context-frame flag (Experimental, per the Node 22 CLI docs).
  • The Node docs only say ALS is a “performant and memory safe” implementation, without giving overhead numbers.

The repo’s micro-benchmark (overhead-bench.ts: 200,000 await + setImmediate chains, each inside an als.run), Node 24.17, each configuration run 2 times x 3 rounds:

Backend Without ALS With ALS
AsyncContextFrame (default) 75 to 323 ms 127 to 158 ms
--no-async-context-frame 186 to 226 ms 182 to 241 ms

The noise between runs is larger than the difference between configurations, so no overhead number can be drawn from this table.

The practical conclusion: ALS overhead is small compared to the real I/O of a request, but if your hot path differs, measure with a load test on your own application rather than trusting someone else’s benchmark.

There are two routes. Both must open the context before guards, interceptors and controllers; middleware runs earliest in the pipeline (request lifecycle), so that is the right place.

1. Wire it yourself with middleware:

export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, _res: Response, next: NextFunction) {
const requestId = validateOrGenerate(req.header('x-request-id'));
als.run({ requestId }, next);
}
}

2. Use nestjs-cls (version 7.0.1 at the time of writing, peer @nestjs/common from 10 up to below 13):

@Module({
imports: [ClsModule.forRoot({ global: true, middleware: { mount: true } })],
})
export class AppModule {}
// in any provider, still a singleton:
constructor(private readonly cls: ClsService) {}
log() { this.cls.get('requestId'); }

According to the nestjs-cls docs, the middleware wraps each request in a CLS context, and ClsService.get/set read and write the store without request-scoped providers. Entry points outside HTTP (Kafka consumers, cron, CLI) need their own context; see the library docs for each transport.

Loading diagram…

  1. Open the context at every entry point: HTTP middleware, consumers, cron, CLI, not just HTTP.
  2. Validate or regenerate a requestId received from the client (length, characters); do not trust it as is.
  3. Use run(), avoid enterWith().
  4. Wrap listeners on a shared EventEmitter and callbacks placed in hand-written queues with AsyncLocalStorage.bind.
  5. Pass the ID via headers or message metadata when crossing a process boundary; ALS does not do that.
  6. Keep the store small; business data and transactions go through explicit parameters.
  7. Test with parallel requests and read the ID before and after every await; assert on IDs, not on log order.
  8. If you suspect overhead, load test with and without ALS on your actual workload.

Self-check: (1) Why can an EventEmitter listener log the wrong requestId? (2) What does getStore() return outside run()? (3) Why must a Kafka consumer open a new context even though the producer already uses ALS?

Run the demos:

Terminal window
npx --prefix examples ts-node examples/src/async-local-storage/context-demo.ts
npx --prefix examples ts-node examples/src/async-local-storage/context-loss-demo.ts
npx --prefix examples ts-node examples/src/async-local-storage/overhead-bench.ts