AsyncLocalStorage and async context

Node.js · Expert · 16 min

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) — run fn with store as the context; everything fn starts asynchronously sees the same store.
  • als.getStore() — read the current store anywhere below, or undefined outside any run.
  • 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 everything fn starts asynchronously
  • getStore() reads it anywhere below — ideal for request IDs and tracing
  • Use bind when 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.

Check yourself

  1. Two requests interleave across awaits. Why does AsyncLocalStorage return the right requestId for each?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/docs/latest-v22.x/api/async_context.html