What AsyncLocalStorage is and how it works

AsyncLocalStorage is a class exported from Node's node:async_hooks module. It lets you attach a value, the store, to a chain of asynchronous work and read it back later without passing it through function arguments.

The API has two core methods. run(store, callback) calls callback synchronously with store active. The store stays active for the callback and for every async continuation created inside it: promise reactions, await resumptions, timers, and I/O callbacks. getStore() returns the active store, or undefined when called outside any run().

Consider a handler that calls run() with an object holding a correlation ID. Inside, it awaits a database helper, which awaits a cache lookup, which schedules a setTimeout. When the timer fires, getStore() still returns the same object, even though the original call stack is gone. Node propagates the store along the async resource graph, not the call stack. Two requests interleaving on the event loop each see only their own store, because each continuation inherits from the context that created it.

This is why backend and platform engineers use it. A server handling hundreds of concurrent requests needs every log line and outbound call tagged with the right request's ID. Threading a ctx parameter through every function works, but it pollutes signatures and breaks when third-party code sits in the middle.

Before this API existed, libraries such as continuation-local-storage and cls-hooked did the same job by monkey-patching or relying on lower-level hooks. AsyncLocalStorage is the built-in replacement. It is maintained in core, and Node's own internals are designed to preserve it.

import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';

const requestContext = new AsyncLocalStorage();

// Any function can read the current request's ID without a ctx parameter.
function log(msg) {
  const store = requestContext.getStore();
  console.log(`[${store?.correlationId ?? 'no-context'}] ${msg}`);
}

async function cacheLookup() {
  await sleep(Math.random() * 20); // simulated I/O latency
  // Callback-style timer: the store still propagates after the stack unwinds.
  setTimeout(() => log('timer fired after cache lookup'), 5);
}

async function queryDatabase() {
  await cacheLookup();
  log('database query done');
}

function handleRequest(correlationId) {
  // run() makes the store active for this call and every continuation it creates.
  return requestContext.run({ correlationId }, async () => {
    log('request started');
    await queryDatabase();
    log('request finished');
  });
}

// Two requests interleave on the event loop; each sees only its own store.
await Promise.all([handleRequest('req-a1b2'), handleRequest('req-c3d4')]);

log('outside any run()'); // getStore() returns undefined here

Two concurrent requests each keep their own correlation ID across nested awaits and a setTimeout callback, while code outside run() sees no store.

Establishing request context at the entry point

The earliest middleware or server handler is the one place a request gets its context. Wrap the rest of the request lifecycle in storage.run(store, next) so every callback, promise, and timer created downstream inherits that store. If you register it after body parsing, authentication, or rate limiting, anything those layers log will have no context. Prefer run() over enterWith(). run() scopes the store to a single call tree. enterWith() attaches it to the current execution and can leak it into unrelated work that shares that execution.

Generating or accepting the correlation ID

By default, generate a fresh ID with crypto.randomUUID(). Accept an inbound header such as x-correlation-id only when the caller is trusted, for example traffic from your own gateway or internal services identified by network boundary or mTLS. An ID that a public client controls can be used to spoof traces, merging an attacker's requests into a victim's log stream.

Validate every inbound value before it touches the store:

  • Format: allowlist characters, such as hex, letters, digits, and hyphens. Rejecting newlines and control characters prevents log injection, where a forged line or JSON fragment is smuggled into your log stream.
  • Length: enforce a hard cap, such as 64 characters, so oversized values cannot bloat logs or downstream headers.

Reject invalid values outright and generate a new ID instead. Do not try to sanitize them, because sanitized input is still attacker-shaped.

Set the final ID as a response header before handing off to route handlers, so callers can quote it in bug reports.

The core rule

There is exactly one entry point that writes the store. Nothing else creates, replaces, or mutates request context.

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
import http from 'node:http';

export const requestContext = new AsyncLocalStorage();

// Allowlist: letters, digits, hyphens, 1-64 chars. Rejects newlines, control chars, JSON fragments.
const ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
// Placeholder addresses for your own gateway. Depending on how the server binds, IPv4 peers
// may appear in IPv4-mapped form (::ffff:10.0.0.12), so list the form your server reports.
const TRUSTED_PEERS = new Set(['10.0.0.12', '10.0.0.13']);

function resolveCorrelationId(req) {
  const inbound = req.headers['x-correlation-id'];
  const trusted = TRUSTED_PEERS.has(req.socket.remoteAddress);
  // Invalid or untrusted values are discarded, never sanitized.
  if (trusted && typeof inbound === 'string' && ID_PATTERN.test(inbound)) return inbound;
  return randomUUID();
}

const server = http.createServer((req, res) => {
  const correlationId = resolveCorrelationId(req);
  // Echo the ID before any route code runs so callers can quote it in bug reports.
  res.setHeader('x-correlation-id', correlationId);
  // The only place the store is written. Freezing it prevents in-place changes downstream.
  const store = Object.freeze({ correlationId });
  requestContext.run(store, () => handleRequest(req, res));
});

function handleRequest(req, res) {
  // Downstream code only reads the context and never writes it.
  const { correlationId } = requestContext.getStore();
  console.log(JSON.stringify({ correlationId, method: req.method, url: req.url }));
  res.end('ok\n');
}

server.listen(3000);

A single Node.js HTTP entry point validates or generates a correlation ID, echoes it on the response, and wraps the request in AsyncLocalStorage.run() so this is the only place the store is written.

Designing a small, immutable store with typed accessors

The store itself should stay boring. Three fields cover most needs: a correlation ID string, the request start time as a number, and an optional user or tenant ID. Freeze the object when the entry point creates it. Every async continuation spawned by the request holds a reference to that one object, so any mutation is visible to all of them at once. Freezing turns an accidental write into an immediate error in strict mode, or a silent no-op in sloppy mode, instead of a race condition.

Accessors, not getStore() everywhere

Expose small functions such as getCorrelationId() and getTenantId() from a single module. They give you one place to handle the "no active context" case, since getStore() returns undefined outside a run(). They also give you one place to add types, and a seam you can stub in tests. Code that calls getStore() directly couples itself to the storage mechanism and to the field names.

Pitfalls

  • Shared writes across Promise.all. All branches of a Promise.all see the same store object. If one branch sets store.step = 'billing', another branch reads that value mid-flight.
  • Sub-scopes. If a branch needs different context, call run() again with a new frozen object that copies the parent's fields. The child context ends when that callback's async work finishes, and the parent's store is untouched.
  • Retention. Pending timers, promises, and other async resources keep the store reachable until they settle. A request body or ORM entity stored there lives as long as the slowest straggler.
  • Hidden dependencies. A function that reads a tenant ID from context does not show that dependency in its signature. Core business logic should take tenant and user as explicit parameters. Keep implicit context for cross-cutting concerns such as logging and tracing.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

// Small and flat on purpose: no request bodies, no ORM entities.
export interface RequestContext {
  readonly correlationId: string;
  readonly startedAt: number;
  readonly tenantId?: string;
}

const als = new AsyncLocalStorage<RequestContext>();

// Entry point: build and freeze the store once per request.
export function runWithContext<T>(fn: () => T, tenantId?: string, correlationId: string = randomUUID()): T {
  const ctx: RequestContext = Object.freeze({ correlationId, startedAt: Date.now(), tenantId });
  return als.run(ctx, fn);
}

// Sub-scope: copy the parent's fields into a new frozen object. The parent store is never mutated.
export function runWithOverrides<T>(overrides: Partial<RequestContext>, fn: () => T): T {
  const parent = als.getStore();
  if (!parent) throw new Error('runWithOverrides called outside a request context');
  return als.run(Object.freeze({ ...parent, ...overrides }), fn);
}

// Accessors are the only code that touches getStore(), so the 'no active context' case is handled in one place.
export function getCorrelationId(): string {
  return als.getStore()?.correlationId ?? 'no-context';
}

export function getTenantId(): string | undefined {
  return als.getStore()?.tenantId;
}

export function getElapsedMs(): number | undefined {
  const ctx = als.getStore();
  return ctx ? Date.now() - ctx.startedAt : undefined;
}

A single context module creates a small frozen store per request. It supports sub-scopes through a nested run() with a copied object, and it exposes typed accessors so the rest of the code never calls getStore() directly.

Wiring correlation IDs into logs, outbound calls, and background jobs

Logs

The logger is where correlation IDs pay off first. Do not pass the ID into every log call. Wrap the logger so each write reads the store and merges the ID into the structured fields. If your logger supports child loggers, create one child at the entry point with the ID bound and keep it in the store. The ID is then attached once rather than looked up on every line. Either way, callers keep calling log.info() with no new parameters. Lines written outside any scope should carry no ID, or an explicit marker such as "context": "none". That makes gaps easy to find.

Outbound calls

Context ends at the process boundary, so outbound calls must carry it explicitly. Put header injection in one place: a shared HTTP client or an interceptor that sets a header such as x-correlation-id from the store. Use the same approach for message queues by writing the ID into message headers or attributes, not into the payload body. Consumers can then read it without parsing business data.

Background jobs

On the consumer side, read the ID from the message metadata. Open a fresh run() scope for each job, and generate a new ID if the message has none. Never reuse one scope across a batch. If you do, every job in the batch logs the first job's ID.

Non-request entry points

Cron handlers, startup code, and CLI scripts have no incoming request. Wrap each cron tick in its own run() with a generated ID. For code that can run before any scope exists, make the accessors return undefined safely instead of throwing.

Relationship to distributed tracing

This in-process store is separate from cross-service trace propagation, but the two can share identifiers. If you already propagate the W3C Trace Context traceparent header, you can use its trace ID as the correlation ID. Log lines and traces then join on a single key.

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

const als = new AsyncLocalStorage();

// Safe accessor: returns undefined outside any scope instead of throwing.
const getCorrelationId = () => als.getStore()?.correlationId;

// Logger wrapper: callers never pass the ID; each line reads it from the store.
const log = {
  info(msg, fields = {}) {
    const id = getCorrelationId();
    console.log(JSON.stringify({ level: 'info', msg, ...fields,
      ...(id ? { correlationId: id } : { context: 'none' }) }));
  },
};

// One place injects the ID into outbound HTTP calls (global fetch, Node 18+).
async function httpGet(url) {
  const id = getCorrelationId();
  return fetch(url, { headers: id ? { 'x-correlation-id': id } : {} });
}

// Queue consumer: read the ID from message metadata, not the payload body.
// Open a fresh scope per message so jobs in a batch never share an ID.
async function handleBatch(messages, handler) {
  for (const msg of messages) {
    const correlationId = msg.headers?.['x-correlation-id'] ?? randomUUID();
    await als.run({ correlationId }, () => handler(msg));
  }
}

// Cron tick: there is no incoming request, so create the context explicitly.
setInterval(() => {
  als.run({ correlationId: randomUUID() }, () => log.info('cleanup tick'));
}, 60_000);

// Startup code runs outside any scope, so this line logs "context": "none".
log.info('service starting');

A single AsyncLocalStorage store feeds a logger wrapper and an outbound header, gives each queue message and cron tick its own run() scope, and returns undefined safely outside any scope.

Where context gets lost and how to test for it

Context loss is rarely loud. The accessor returns undefined, or worse, a different request's store, and logs keep flowing with the wrong or missing ID.

Common causes

  • Callback-based libraries that queue callbacks internally and invoke them later from their own I/O handling. The callback runs in whatever context triggered the flush, not the one that registered it.
  • Custom thenables and hand-rolled promise libraries. Native promises propagate context, but objects that implement then themselves may call your continuation from a context they captured elsewhere.
  • Connection pools. A request waiting for a free connection is often resumed when another request releases one. That continuation can inherit the releasing request's store, which means the wrong correlation ID rather than none.
  • Event emitters. Listeners run synchronously in the context of whoever calls emit, not whoever called on.

The fix is to capture the caller's context at registration time. AsyncResource.bind (or the static AsyncLocalStorage.bind) wraps a function so it always runs in the context that existed when it was bound. Apply it at the boundary where you hand a callback to the library.

Testing

Run unit tests inside run() with a fixture store so accessors behave as they do in production. Add a concurrency test that fires many parallel requests through real middleware, each with a distinct correlation ID, and asserts that every log line and outbound header carries its own request's ID. Pool contention and interleaved I/O are what expose leaks, so make the test concurrent enough to cause them.

In code paths that should always have a store, have the accessor log a warning when none is found. The warning turns a silent gap into a signal you can trace to a specific library call.

When to use it, when to avoid it, and the alternatives

AsyncLocalStorage fits data that is created once per request, read in many places, and only matters for the lifetime of that request. Correlation IDs, a tenant identifier for scoping queries, and a logger already bound to request fields all fit. Code deep in the call stack reads these values, but it should not have to declare them in its own signature.

Avoid it as a general dependency-injection mechanism. A repository that reads its database client from ambient context has a hidden input. It fails at runtime instead of at construction, and every test has to set up a store before calling it. Avoid it for large or mutable state too. The store lives as long as every pending callback that captured it, and writes made in one branch of concurrent work show up in the others. It also does not replace tracing. It puts the same ID on every log line, but it records no spans, no timing, and no view of a request across services.

Approach Strength Limitation
Explicit parameter passing Dependencies visible in signatures; trivial to test Threading values through every layer is noisy
Framework request object No extra mechanism inside handlers Unavailable in code outside the framework's reach
Older cls / async_hooks libraries Same ambient model Mutable namespaces with set/get; migrating means replacing them with run(store, fn) and an immutable store
Distributed tracing Spans, latency, cross-service graphs More setup, instrumentation, and backend cost

Impact on cost and engineering time

Runtime overhead

AsyncLocalStorage is not free. Each async resource has to carry the active store forward, and that bookkeeping adds a small cost to every promise, timer, and callback created inside a run() scope. For a typical handler that waits on a database or an upstream HTTP call, that cost is usually small next to the I/O. It can matter more in CPU-heavy code that creates many short-lived promises. Measure it in your own workload: compare p50/p99 latency and CPU per request with context enabled and disabled under realistic load, rather than relying on someone else's microbenchmark.

Engineering time

The upfront work is modest but real: the accessor layer, propagation helpers for queues and outbound clients, and the context-loss tests from section 5. The return comes during incidents. One correlation ID lets you grep logs across services instead of reconstructing a request from timestamps. You also stop threading a ctx parameter through every function signature.

Illustrative estimate (assumed numbers, not a benchmark)

  • Setup cost = 3 engineer-days
  • Time saved per incident = 1.5 hours
  • Incidents needing cross-service tracing = 4 per month

Break-even (months) = setup hours ÷ (hours saved per incident × incidents per month) = 24 ÷ (1.5 × 4) = 4 months

Plug in your own figures. If the result runs into years, the case is weak.

Ongoing cost

The store stays cheap only if it stays small. Every field someone adds "because it's convenient" turns the store into hidden global state that is harder to reason about. Budget review time to reject new fields that belong in function arguments instead.

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 route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.