Logging, tracing and per-request state without threading a context argument through every function.
In a server, many requests interleave on one thread, so a global "current request" variable is wrong as soon as you await. `AsyncLocalStorage` (from node:async_hooks) solves this: it gives each logical async flow its own store, which follows the code through await, promise callbacks, timers and event-emitter callbacks created inside that flow.
The API is small:
als.run(store, fn)— runfnwithstoreas the context; everythingfnstarts asynchronously sees the same store.als.getStore()— read the current store anywhere below, orundefinedoutside anyrun.als.enterWith(store)— set the store for the rest of the current synchronous execution; avoid it except in special integrations.
The classic use is a request ID or trace context: middleware calls als.run({ requestId }, next) and your logger reads als.getStore()?.requestId, so every log line is correlated without passing anything around. OpenTelemetry and APM agents use the same mechanism.
Pitfalls come from context that is captured at the wrong time. Callback-based libraries that keep their own queue of callbacks (some connection pools) may run your callback in another request's context. AsyncResource.bind(fn) or AsyncLocalStorage.bind(fn) captures the current context into a function so it runs with the right store later. Also keep stores small and never cache them globally — they hold references for as long as the async flow lives.
Under the hood, Node 22 builds this on async_hooks (createHook with init/before/after callbacks for every async resource), plus promise hooks. That low-level API is costly and its hook-based parts are discouraged for application code; use AsyncLocalStorage instead. Node 22 also offers --experimental-async-context-frame (the default from Node 24), which stores context in V8's continuation-preserved embedder data and makes propagation much cheaper.
Example
import { AsyncLocalStorage } from "node:async_hooks";
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { setTimeout as sleep } from "node:timers/promises";
const context = new AsyncLocalStorage();
const log = (msg) => console.log(`[${context.getStore()?.requestId ?? "-"}] ${msg}`);
async function loadProfile(user) { // no request object passed in
await sleep(Math.random() * 50);
log(`loaded profile for ${user}`);
return { user };
}
createServer((req, res) => {
context.run({ requestId: randomUUID().slice(0, 8) }, async () => {
log(`${req.method} ${req.url}`);
const profile = await loadProfile(req.url.slice(1) || "anon");
res.end(JSON.stringify(profile));
log("done");
});
}).listen(3000);
// Concurrent requests log with their own IDs despite interleaving.Remember
als.run(store, fn)scopes a store to everythingfnstarts asynchronouslygetStore()reads it anywhere below — ideal for request IDs and tracing- Use
bindwhen a library runs your callback from a shared queue - Prefer AsyncLocalStorage over raw async_hooks
Watch out for
- Storing the current request in a module-level variable, which leaks between concurrent requests.