10LOC
Node.jsadvanced

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() calls requestContext.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() returns undefined, which is why log falls 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 an EventEmitter with 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 alongside AsyncLocalStorage when 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.