AsyncLocalStorage for request-scoped trace IDs across async calls
Published November 6, 2026
import { AsyncLocalStorage } from "node:async_hooks";
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
const requestContext = new AsyncLocalStorage<string>();
export const log = (message: string) =>
console.log(`[${requestContext.getStore() ?? "no-trace"}] ${message}`);
createServer((req, res) => {
requestContext.run(randomUUID(), () => {
log(`handling ${req.method} ${req.url}`);
res.end("ok");
});
}).listen(3000);What
AsyncLocalStorage holds a value that's automatically available to every function called — directly or through any number of awaits, callbacks, or timers — from within a .run() call, without threading it through every function signature.
Why it matters
A trace ID (or request ID, tenant ID, logged-in user) is the textbook case for "context every layer needs but shouldn't have to accept as a parameter." The naive options are both bad: pass it explicitly through every function call down the stack (works, but pollutes every signature with a parameter unrelated to what the function actually does), or stash it on a module-level variable (breaks immediately under concurrency — two requests in flight at once will stomp on each other's value).
AsyncLocalStorage solves this properly because Node's async machinery tracks causality, not just call stacks: anything that gets scheduled as a result of code running inside .run() — a Promise continuation, a setTimeout, an event handler — still sees that same store, even though by the time it runs, the original synchronous call stack is long gone. Two concurrent requests each get their own isolated store, correctly, with no extra bookkeeping.
How it works
new AsyncLocalStorage<string>()creates one storage instance — typically a module-level singleton shared across the whole app, not one per request.requestContext.run(randomUUID(), () => { ... })starts a fresh async context scoped to everything called inside that callback, seeded with a new trace ID per request.log()callsrequestContext.getStore()to read whatever trace ID is active for the call currently in flight — it needs no request object passed in, because the context is ambient, not explicit.- Outside of any
.run()call,getStore()returnsundefined, which is whylogfalls back to"no-trace".
Gotchas
- Prefer
.run()over.enterWith().enterWith()sets the store for the rest of the current synchronous execution and everything scheduled after it, with no defined end — in an event-driven context (like anEventEmitterwith multiple listeners on the same event), that can leak one handler's context into the next. - The store is only as good as what's inside the
.run()callback. Code that escapes it — e.g. work queued before the request handler ran, or a background job scheduled outside any request — won't see it. - There is a small but real per-call overhead. It's negligible for HTTP request handling; think twice before using it inside a hot, high-frequency inner loop.
Related
events.once()— a different async primitive; useful alongsideAsyncLocalStoragewhen a request handler needs to wait on an event without losing its context (it doesn't).- Explicit context passing (a plain object threaded through function parameters) — more verbose, but the types make the dependency visible instead of implicit.