AsyncLocalStorage, from Node’s built-in node:async_hooks module, keeps a value alive across every asynchronous hop of a single request. Wrap the handler in run(), and any function called downstream reads the same store through getStore() with no context argument threaded through the call chain. The class has been Stable since Node 16.4.
That solves a specific problem. You want a request ID, a tenant, a user, a trace correlation ID available in a repository function eight frames down, and you do not want to add a ctx parameter to eight function signatures to get it there.
How do you set up AsyncLocalStorage for request context in Node?
One module owns the storage instance. Nothing else touches it directly.
// src/context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
export interface RequestContext {
requestId: string;
tenantId?: string;
startedAt: number;
}
const storage = new AsyncLocalStorage<RequestContext>();
export function runWithContext<T>(ctx: RequestContext, fn: () => T): T {
return storage.run(ctx, fn);
}
export function getContext(): RequestContext | undefined {
return storage.getStore();
}
export function newContext(requestId: string = randomUUID()): RequestContext {
return { requestId, startedAt: Date.now() };
}
Then one middleware, registered before everything else, opens the scope for the lifetime of the request:
import express from 'express';
import { getContext, newContext, runWithContext } from './context.js';
const app = express();
app.use((req, res, next) => {
const ctx = newContext(req.header('x-request-id'));
res.setHeader('x-request-id', ctx.requestId);
runWithContext(ctx, next);
});
app.get('/orders/:id', async (req, res) => {
res.json(await loadOrder(req.params.id));
});
async function loadOrder(id: string) {
// No ctx parameter, no globals, no request object in scope.
console.log({ msg: 'loading order', id, requestId: getContext()?.requestId });
return { id };
}
run() invokes the callback synchronously and returns its value, so calling next() inside it is enough. Everything Express dispatches after that point, including async handlers and the promises they await, sits inside the store. Fastify is the same shape as an onRequest hook, and plain node:http is the same again inside the request listener.
The payoff shows up in logging. Pino’s mixin option runs on every log call and merges the object it returns into the line, so one function turns every log statement in the process into a correlated one:
import pino from 'pino';
import { getContext } from './context.js';
export const logger = pino({
mixin() {
const ctx = getContext();
return ctx ? { requestId: ctx.requestId, tenantId: ctx.tenantId } : {};
},
});
No log call site changes. That is the whole reason to reach for this API.
Where does the context go missing?
Context follows asynchronous operations created inside the scope. It does not follow a callback registered outside it. The Node docs are blunt about the usual culprit: event listeners “may be run in a different execution context than the one that was active when eventEmitter.on() was called”.
So a listener attached at start-up, on a shared queue or a database pool, sees undefined even when the emit() happens mid-request. Same for anything you hand to a connection pool created before the server started, and for thenables that are not real promises.
The fix is to capture the context where you schedule the work rather than where it runs:
import { AsyncLocalStorage } from 'node:async_hooks';
import { getContext } from './context.js';
// Wrong: registered once at boot, so getContext() is undefined when it fires.
queue.on('job:done', (job) => logger.info({ job }, 'done'));
// Right: snapshot at schedule time, replay the context at run time.
function enqueue(job: Job) {
const runInContext = AsyncLocalStorage.snapshot();
queue.push(job, (err, result) => runInContext(() => handle(err, result)));
}
AsyncLocalStorage.snapshot() and AsyncLocalStorage.bind() are both static, both landed in Node 19.8 and 18.16, and both went Stable in 23.11 and 22.15. snapshot() captures the whole active context and gives you back a function that replays it; bind() wraps a single function in the current context. They replace most uses of AsyncResource for this job.
When you cannot work out where a store vanished, the docs suggest the crude method, and it works: log getStore() after each call you suspect, and the last callback before the undefined is your culprit.
Should you use run() or enterWith()?
Use run(). enterWith() is still marked Stability 1 (Experimental) and it does not scope itself the way you expect. It sets the store for the remainder of the current synchronous execution and everything that follows, so subsequent event handlers on the same tick inherit a store that was never meant for them. The API reference says it plainly: prefer run() unless you have strong reasons not to.
exit() and disable() carry the same experimental marker. disable() also has a lifecycle footnote worth knowing if you create storage instances dynamically: it must be called before the instance can be garbage collected.
Node 25.9 added withScope(), which returns a disposable and pairs with using from explicit resource management. It is experimental, and it has a sharp edge in async functions. Called before the first await, the scope stays active in the caller after the promise is returned, which is almost never what you want. Treat it as a synchronous-block tool for now, and keep run() for request scopes.
What does AsyncLocalStorage cost?
Enough to measure, not enough to avoid. Node 24 made AsyncContextFrame the default implementation, and the release announcement describes it as “a more efficient implementation of asynchronous context tracking”. No code change is needed to get it.
Platformatic’s benchmark of the change puts numbers on it: on Node 24.4.1 an instrumented server held 53,450 req/sec against a 57,301 req/sec baseline, roughly 7% off. The same test on Node 22.17.1 gave 50,913 against 56,446, closer to 10% off. Their hardware, their harness, not ours, so treat the ratio as the signal rather than the absolute throughput.
Two practical conclusions. Upgrading to Node 24 or later is the cheapest performance work available to anyone already running this API. And a single storage instance holding one object costs less than several instances holding several, so keep it to one.
Does it work on Cloudflare Workers and other runtimes?
Yes, with caveats worth reading before you port code. Cloudflare Workers supports run(), getStore(), exit(), AsyncLocalStorage.bind(), AsyncLocalStorage.snapshot() and AsyncResource. It deliberately omits enterWith() and disable(), and thenables are not fully supported there, so snapshot() is the recommended escape hatch.
The gate is a compatibility date. On 2026-08-04 and later it is on by default; before that you need nodejs_compat in your Wrangler config. If you are weighing up where request state should live on that platform in the first place, our comparison of Cloudflare’s storage primitives covers the durable end of that question.
OpenTelemetry’s Node instrumentation runs on the same machinery. Its @opentelemetry/context-async-hooks package ships an AsyncLocalStorageContextManager, which is why a manually created span picks up the right parent without being handed one. If you are wiring that up, our guide to getting OpenTelemetry actually tracing covers the exporter side.
What belongs in the store, and what does not
Identifiers and small immutable facts. Request ID, tenant ID, authenticated user ID, locale, a feature-flag snapshot taken once at the start of the request.
Not the request or response objects, because that makes every layer a web layer again and defeats the point. Not mutable accumulators that later code writes into, because you have then built a global variable with extra steps and no type safety at the boundaries. Not anything you need after the response is sent, unless you have taken a snapshot: work deferred past the response is exactly where contexts get dropped, which is the same trap that catches people using Next.js after() for background work.
Keep the store read-only after creation and the whole thing stays easy to reason about.
What we would do
Add it to any Node service where you already wish log lines carried a request ID. One context.ts module, one middleware at the top of the chain, one mixin in the logger, and stop there. Do not let it become the way services pass real arguments around, because a function whose behaviour depends on an invisible ambient store is harder to test and harder to read than one that takes a parameter.
Skip it if you are on Node 22 or older and running hot enough for a 10% throughput cut to matter, and either upgrade first or pass the context explicitly on the hot path. Skip it too in a library you publish, where the caller’s runtime and context boundaries are not yours to assume.