What AsyncLocalStorage is and how request context propagates

AsyncLocalStorage, exported from the node:async_hooks module, holds a value (the "store") that follows asynchronous execution rather than the call stack. When you bind a store, every async operation created inside that context inherits it: promises, timers, callbacks, and the continuations after an await. You can read it later with getStore() from anywhere downstream, without passing it as an argument. Outside any bound context, getStore() returns undefined.

This is why it is used for request IDs, trace context, tenant identifiers and request-scoped logging. Without it, every function between the HTTP handler and the database call needs an extra parameter just to carry metadata. The engineers who benefit most run many concurrent requests in one process. In that setting, a module-level variable would be overwritten by whichever request ran last.

run(store, callback) is the API to reach for first. It binds the store for the duration of the callback and everything the callback schedules, then restores the previous context when the callback returns. The scope is explicit and bounded.

enterWith(store) binds the store with no callback. It applies to the rest of the current synchronous execution and to any async work started from that point. If you call it in shared middleware or at module load, the store can leak into code you did not intend to scope. Prefer run() unless you have a specific reason not to.

incoming request
      │
      ▼
 als.run(store, handler) ── store = { requestId: "a1" }
      │
      ▼
  Promise.all([...])
   ┌──────┼──────┐
   ▼      ▼      ▼
 fetchA fetchB fetchC
   │      │      │
 getStore() in each child returns the same store object
// Node.js ESM example: run() scopes a store to a request and its async children.
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

const als = new AsyncLocalStorage();

// Deep helper: reads context without receiving it as a parameter.
function log(msg) {
  const store = als.getStore();
  console.log(`[${store?.requestId ?? 'no-context'}] ${msg}`);
}

async function fetchPart(name, ms) {
  await sleep(ms); // context survives the await
  log(`${name} done`);
  return als.getStore();
}

async function handler() {
  // Each child inherits the store bound by run().
  const stores = await Promise.all([
    fetchPart('fetchA', 30),
    fetchPart('fetchB', 10),
    fetchPart('fetchC', 20),
  ]);
  // Every child saw the same store object, not a copy.
  log(`same reference: ${stores.every((s) => s === stores[0])}`);
}

// Two concurrent "requests" do not overwrite each other's context.
const first = als.run({ requestId: 'a1' }, handler);
const second = als.run({ requestId: 'b2' }, handler);

// Outside run(), there is no bound store.
log('after run() returned'); // prints [no-context]

await Promise.all([first, second]);

Two concurrent run() calls each bind their own store, the Promise.all children of each request read that request's store object by reference, and getStore() returns undefined outside any run() scope.

Implementing request context: entry points, store contents and immutability

Call run() exactly once per unit of work, at the outermost point where that work begins: the HTTP server's request handler, the first middleware in the chain, or the callback a message consumer invokes per message. The store is bound to async resources created inside the callback. Anything scheduled before you enter, such as a body-parsing stream already listening or a promise chain started by an earlier middleware, keeps the context it was created under. Entering late therefore gives you a request where some log lines carry an ID and others carry nothing.

Keep the store small and descriptive of the request. It is ambient state that any function can read without declaring a dependency on it, so it should hold identifiers, not capabilities.

Item Store or pass explicitly Reason
Request ID Store Read-only, needed everywhere
Trace/span IDs Store Used by logging and outbound calls
Tenant or user ID Store Small, set once after authentication
Child logger bound to the IDs Store Avoids rebuilding it per call
DB connection or transaction Pass explicitly Lifetime and ownership must be visible
Parsed request body, large payloads Pass explicitly Retained for the life of every pending callback
Services, clients, config Pass explicitly Hidden dependencies make code hard to test

getStore() returns the same object reference to every concurrent branch. If one branch in a Promise.all writes to it, its siblings see the write at whatever point the scheduler happens to interleave them. That makes the behavior race-like. For a sub-scope such as a new span, call run() again with a shallow copy of the current store plus the changed field. Freeze the store when every value is known at entry. Use a Map only when a later middleware must fill a slot once, such as the user ID after authentication, and no concurrent branch writes to it afterward.

const http = require('node:http');
const { AsyncLocalStorage } = require('node:async_hooks');
const { randomUUID } = require('node:crypto');

const als = new AsyncLocalStorage();
const db = { query: async (sql, params) => [] }; // stand-in; passed explicitly, never stored

const makeLogger = (fields) => ({ info: (msg) => console.log(JSON.stringify({ ...fields, msg })) });
const log = (msg) => als.getStore()?.logger.info(msg);

// Sub-scope: shallow-copy the parent store, change only the span, enter a nested run().
function withSpan(name, fn) {
  const parent = als.getStore();
  const spanId = randomUUID().slice(0, 16);
  const logger = makeLogger({ requestId: parent.requestId, tenantId: parent.tenantId, spanId, span: name });
  return als.run(Object.freeze({ ...parent, spanId, logger }), fn);
}

// Dependencies come in as arguments; only identifiers come from the store.
async function loadOrders(conn, tenantId) {
  log('loading orders');
  return conn.query('SELECT id FROM orders WHERE tenant_id = $1', [tenantId]);
}

http.createServer((req, res) => {
  // Enter once, before any other async work for this request is scheduled.
  const requestId = req.headers['x-request-id'] ?? randomUUID();
  const tenantId = 'tenant-42'; // in real code, set from the authenticated principal
  const store = Object.freeze({ requestId, tenantId, spanId: 'root', logger: makeLogger({ requestId, tenantId }) });

  als.run(store, async () => {
    try {
      // Concurrent branches each get their own frozen copy, so no shared mutation.
      const [orders] = await Promise.all([
        withSpan('orders', () => loadOrders(db, tenantId)),
        withSpan('audit', async () => log('audit recorded')),
      ]);
      log('request done');
      res.end(JSON.stringify(orders));
    } catch (err) {
      log(`request failed: ${err.message}`);
      res.statusCode = 500;
      res.end();
    }
  });
}).listen(3000);

An HTTP handler that calls run() once at entry with a small frozen store of identifiers and a child logger, uses nested run() with a shallow copy for per-span sub-scopes under Promise.all, and passes the database handle explicitly.

Request-scoped logging as the canonical use case

Logging is where request context pays for itself first. At the entry point, after the request ID is resolved, call rootLogger.child({ requestId, method, route }) and put the result in the store next to the ID. Every log line written through that child carries the bound fields. Code deep in a repository or an HTTP client wrapper never has to see them.

Code reaches the logger through one helper: getLogger() returns als.getStore()?.logger ?? rootLogger. The fallback is not optional. Several kinds of code run outside any run() call, and getStore() returns undefined there:

  • module initialisation and startup code such as config loading and migration checks
  • cron-style background jobs
  • most unit tests

Without the fallback, the helper would need a guard at every call site. Without it, a shared utility would throw the first time it ran outside a request. With the fallback, the same function logs correctly in every setting, and its uncorrelated lines show plainly that no request context was active.

The engineering-time saving comes from function signatures. You don't need to thread a logger or ctx parameter through dozens of functions, and you don't need to touch every caller when a new field is added. During an incident, a single filter on requestId returns the full path of a failing request across middleware, services and outbound calls. You no longer have to piece it together from timestamps.

The cost is one child-logger allocation per request, plus whatever binding work the logger does. That cost is small, but it scales with throughput. This is one more reason to keep the store minimal: a logger and a few identifiers, not a copy of the request object or bulky user records that live as long as the request's async chain does.

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';

// Library-agnostic logger shape: any logger with child() fits.
interface Logger {
  info(msg: string, fields?: Record<string, unknown>): void;
  error(msg: string, fields?: Record<string, unknown>): void;
  child(bindings: Record<string, unknown>): Logger;
}

// Keep the store minimal: identifiers and a logger, not the request object.
interface RequestContext {
  requestId: string;
  logger: Logger;
}

declare const rootLogger: Logger; // created once at startup by your logging library
const als = new AsyncLocalStorage<RequestContext>();

// Falls back to rootLogger outside run(): startup code, cron jobs, unit tests.
export function getLogger(): Logger {
  return als.getStore()?.logger ?? rootLogger;
}

// Entry point: one child logger per request, bound fields set once.
export function withRequestContext(req: IncomingMessage, res: ServerResponse, next: () => void): void {
  const header = req.headers['x-request-id'];
  const requestId = typeof header === 'string' ? header : randomUUID();
  const logger = rootLogger.child({ requestId, method: req.method, route: req.url });
  als.run({ requestId, logger }, next);
}

// Deep in the call stack: no logger parameter threaded through signatures.
export async function chargeCard(orderId: string): Promise<void> {
  getLogger().info('charging card', { orderId }); // carries requestId, method, route
}

A per-request child logger stored in AsyncLocalStorage. A getLogger() helper reads it and falls back to the root logger when getStore() returns undefined, so code deep in the stack logs with request fields without taking a logger parameter.

Lost and leaked context: causes, diagnosis and fixes

Context loss and leakage do not throw errors. They show up as log lines missing a requestId, or as a query tagged with the wrong tenant. Neither trips an alert, so you need to look for them on purpose.

Where context gets lost

The common cause is a library that queues your callback and runs it later from its own internal context. Connection pools that hand out connections from a shared queue do this, as do custom thenables that are not native promises and older callback-based clients. Two other causes are less obvious:

  • Work scheduled before run(). Module-level timers and startup hooks never had a store to begin with.
  • Worker threads and child processes. These are separate isolates, and context does not cross into them. Pass identifiers explicitly in the message.

Where context leaks

EventEmitter listeners run in the context of whoever calls emit(), not the context where the listener was registered. A singleton emitter that is emitted during request A therefore runs every listener with A's store. Lazily created resources have a similar problem. If a pool or client is created inside the first request, it can capture that request's context for any internal callbacks it schedules. Later requests then log under the first request's ID.

Re-attaching context

These tools fix the binding at a known point:

  • AsyncLocalStorage.bind(fn) binds a callback to the current context.
  • AsyncResource.bind(fn) does the same through the async resource API.
  • Subclassing AsyncResource and wrapping invocations in runInAsyncScope() works for custom queues.

Testing it

Start many concurrent requests, each with a unique ID and randomized delays. At the deepest call sites, such as after a pooled query or inside an emitter listener, read getStore(). Assert that the store is defined and that its ID matches the request's own ID.

// context.test.mjs — run with: node --test
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

const als = new AsyncLocalStorage();

// Simulated pool: callbacks are queued and drained by a timer created at
// module load, outside any run(), so unbound callbacks would see no store.
const queue = [];
const drainer = setInterval(() => queue.splice(0).forEach((cb) => cb()), 5);

function pooledQuery(cb) {
  // The fix: bind the callback to the caller's context before queuing it.
  // Remove AsyncLocalStorage.bind and this test fails.
  queue.push(AsyncLocalStorage.bind(cb));
}

async function handler() {
  await sleep(Math.random() * 20); // randomize interleaving between requests
  // Deep call site: read the store inside the pooled callback.
  return new Promise((resolve) => pooledQuery(() => resolve(als.getStore())));
}

test('request context survives pooled callbacks under concurrency', async (t) => {
  t.after(() => clearInterval(drainer));
  const ids = Array.from({ length: 50 }, (_, i) => `req-${i}`);
  const stores = await Promise.all(
    ids.map((requestId) => als.run({ requestId }, handler)),
  );
  stores.forEach((store, i) => {
    assert.ok(store, 'getStore() was undefined at a deep call site');
    assert.equal(store.requestId, ids[i], 'context leaked from another request');
  });
});

A node:test check that runs 50 concurrent requests through a simulated pool whose queue is drained outside any run(). It asserts that getStore() is defined and holds each request's own ID, which holds only because the queued callbacks are wrapped with AsyncLocalStorage.bind.

Performance: where the cost comes from and how to measure it

The cost of AsyncLocalStorage does not come from getStore(), which is a cheap lookup. It comes from propagation. Every time the runtime creates an async resource or a promise while a store is active, it has to capture the current context and restore it when the continuation runs. That means cost grows with the number of async operations per request. A handler that awaits 300 small promises pays more than one that awaits five, even if neither reads the store.

Illustrative estimate (assumed numbers, not a benchmark). Overhead fraction = (async ops per request × per-op overhead) ÷ request latency.

  • I/O-bound endpoint. Assume 200 ops, 0.5 µs per op and 20 ms latency: 200 × 0.5 µs = 100 µs, or 0.5% of the request.
  • Promise-heavy CPU-bound path. Keep the same 200 ops and 0.5 µs per op but assume 2 ms latency: the same 100 µs is now 5%.

This is why I/O-bound services usually see a small relative cost, while tight promise-heavy hot paths may notice it.

Measure on your own runtime. The implementation has changed across Node.js releases, so older blog posts may describe a different mechanism. Use two complementary tests:

  1. Load test. Run a representative endpoint with context enabled and disabled. Compare throughput and tail latency at the same concurrency.
  2. Microbenchmark. Isolate the promise-heavy path with a minimal harness:
    • a function that awaits N trivial promises
    • a warm-up loop
    • timed iterations run both inside and outside storage.run()
    • repeated runs, reporting the median and spread

NestJS, CLS-style modules and other alternatives

Each of the four approaches decides in a different place who carries the context.

Explicit parameter passing threads a context object through every function signature. It is verbose, but nothing is hidden. A missing argument becomes a compile error, not an empty log field.

NestJS request-scoped providers (@Injectable({ scope: Scope.REQUEST })) make Nest build a new instance for every request. Scope cascades: any provider that injects a request-scoped one also becomes request-scoped. As a result, a single context provider can turn large parts of the DI graph into per-request allocations.

AsyncLocalStorage-backed services keep providers singleton. The pattern looks like this:

  1. An @Injectable() service owns one AsyncLocalStorage instance from node:async_hooks.
  2. A class implementing NestMiddleware calls storage.run(store, next) in its use() method.
  3. Any other singleton reads the store through the service.

Middleware is the safer entry point. In an interceptor, next.handle() returns an Observable that is subscribed after run() has already returned, so the store must be established inside that subscription. Community CLS modules package this pattern, but they add a dependency you have to track across framework upgrades.

Older async_hooks-based CLS libraries managed context maps in user-land hooks. AsyncLocalStorage replaced them with a core API that has a supported propagation model.

Approach Testability Runtime cost profile Framework coupling Silent context loss risk
Explicit parameters High; plain arguments Negligible None None
Request-scoped providers Medium; needs DI test module Per-request instantiation, cascading High Low
AsyncLocalStorage service Medium; wrap tests in run() Per async-resource propagation Low Medium
async_hooks CLS libraries Low Hook callbacks on every resource Low High

When to use it, when to avoid it, and the impact on cost and engineering time

Use AsyncLocalStorage for cross-cutting concerns that every layer needs but no function should own: log correlation, trace IDs, tenant identifiers and auth principals. It is in-process only. Context does not survive an HTTP call or a queue hop. You serialize the fields you need into outgoing headers or message metadata, then restore them on the receiving side with run(). Tracing instrumentation propagates context the same way, so reuse its header conventions when you can instead of inventing parallel ones.

Avoid it in three cases:

  • Business logic inputs. A discount rule that reads tenant tier from the store has a dependency that no signature shows.
  • Large or resource-like objects. Database connections and open streams in the store outlive their intended lifetime when context leaks.
  • Hot paths. Skip it where profiling shows the propagation overhead matters.
Factor Cost Benefit
Testability Hidden dependencies; tests must wrap calls in run() No signature churn when you add a field
Runtime Small per-async-resource overhead Request-scoped logging without threading arguments
Correctness Silent loss or leakage; enterWith() needs care One place to enforce tenancy
Maintenance Nested-run immutability is verbose; boundary propagation needs ongoing upkeep Logs correlate across services

The development cost is front-loaded. You set up entry points, write propagation code for each transport, and add tests that fail when context goes missing after an await or a callback. The return comes every time an incident requires joining logs by request ID, and every time a new field reaches deep call sites without touching dozens of signatures. Teams that debug distributed failures often usually recover that cost quickly. A small single-service app may never recover it.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to asynclocalstorage request context, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.