What request context is and why passing it everywhere breaks down
Request context is the set of values that belong to one inbound request and nobody else: a request ID, the tenant, the authenticated user, trace and span identifiers, and often a logger already tagged with all of these. Handlers rarely need these values. The code that does need them sits several layers down: the database wrapper that adds a tenant filter, the HTTP client that propagates trace headers, the error reporter that attaches the user ID.
The obvious fix is to pass a ctx argument through every function. This works for a while, then stops scaling. Every signature between the edge and the leaf has to carry a parameter it never reads. Adding a new field means touching dozens of files. Third-party callbacks and library hooks have fixed signatures, so the context cannot pass through them, and the chain breaks exactly where you need it most.
The shortcuts are worse. A module-level variable like currentRequestId holds only one value per process. Node runs many requests concurrently on one event loop, so request A sets the variable, awaits a query, and request B overwrites it before A resumes. A's logs now carry B's ID. Attaching fields to req avoids the overwrite, but it only moves the problem: you still have to pass req everywhere, and code outside the HTTP layer has no req to read.
AsyncLocalStorage, exported from node:async_hooks, is the built-in primitive for this problem. It is aimed at backend and platform engineers running services that handle many requests or many tenants at once. It fits cross-cutting concerns such as logging, tracing, tenancy and auditing, which are needed deep in the call stack but have no place in business-logic signatures.
// demo.mjs - run with: node demo.mjs
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
// Unsafe: one slot shared by every in-flight request in the process.
let currentRequestId;
// Safe: each async call chain started inside run() sees its own store.
const requestContext = new AsyncLocalStorage();
// A leaf function several layers down. It takes no ctx argument.
async function queryDb() {
await sleep(10); // simulated I/O: other requests run while we wait
const fromGlobal = currentRequestId;
const fromAls = requestContext.getStore()?.requestId;
console.log(`global=${fromGlobal} als=${fromAls}`);
}
async function handleRequest(requestId, delayMs) {
currentRequestId = requestId; // the shortcut: overwritten by the next request
return requestContext.run({ requestId }, async () => {
await sleep(delayMs); // e.g. auth, parsing
await queryDb();
});
}
// Two requests interleave on the same event loop.
await Promise.all([
handleRequest('req-A', 5),
handleRequest('req-B', 20),
]);
// Output:
// global=req-B als=req-A <- the global leaked B's ID into A's logs
// global=req-B als=req-B
Two interleaved requests show a module-level variable reporting the wrong request ID while AsyncLocalStorage returns the correct one to a deeply nested function that receives no context argument.
What is AsyncLocalStorage?
AsyncLocalStorage is a class built into Node.js and exported from the node:async_hooks module. No package needs to be installed. An instance holds one "store" per asynchronous call chain. The store is any value you choose, usually a small object of identifiers. You can think of it as a variable whose value depends on which request's async work is currently running, not on which function is calling.
The core API is small:
new AsyncLocalStorage()creates a storage instance. You normally create one per concern at module scope.storage.run(store, callback, ...args)runscallbackwithstoreactive. All asynchronous work scheduled from insidecallbacksees the same store.storage.getStore()returns the active store, orundefinedwhen called outside anyrun()scope.storage.exit(callback, ...args)runscallbackwith no store active, which is useful for work that must not inherit the current request's context.storage.enterWith(store)sets the store for the rest of the current execution without a callback. The implementation section below explains whyrun()is usually the better choice.
It does not change how your code runs, add threads, or serialize anything. It only makes a value available to code further down an async chain without passing it as an argument.
Who should use it?
AsyncLocalStorage is a good fit if you:
- Run Node.js services that handle concurrent requests, such as HTTP APIs, GraphQL servers, or message consumers, and need per-request values like request IDs or trace IDs in deep code.
- Operate multi-tenant systems where logs, metrics and audit records must be labelled with the correct tenant.
- Build platform or shared libraries like logging wrappers, tracing integrations, or error reporters that need request metadata without asking every caller to pass it in.
- Maintain large codebases where threading a
ctxparameter through every layer has become a recurring source of churn.
It is a weaker fit for short-lived scripts and CLIs that handle one task at a time, where a plain variable is correct. It also suits code where the value in question changes business behavior, which should remain an explicit argument, as covered in the final section. Teams new to it should also be prepared to test propagation explicitly, because failures are silent.
How AsyncLocalStorage works: context follows async execution, not threads
A Node.js process typically serves every in-flight request on a single thread, so a store cannot be keyed by thread. It is also not attached to a request object, because the runtime does not know what a request is. Instead, the store is bound to an asynchronous execution context. When you call storage.run(store, fn), fn runs with store active. Any asynchronous work scheduled from inside fn captures that context at the moment it is created. This includes promise continuations after an await, setTimeout and setImmediate callbacks, and callbacks from I/O started there. When that work later executes, the same store is restored for it. Code scheduled from outside run() has no store, and getStore() returns undefined.
This is why interleaving is safe. Two requests can alternate on the event loop many times, and each continuation still sees its own store:
event loop ──▶ time
[A: run(storeA)] [B: run(storeB)] [A: after await] [B: timer] [A: db callback]
getStore()=A getStore()=B getStore()=A =B =A
The store follows the causal chain of "who scheduled this", not the order in which callbacks happen to execute.
AsyncLocalStorage is exported from the node:async_hooks module and relies on the runtime's async context tracking, the same propagation that async_hooks exposes at a low level. createHook gives you raw init, before, after and destroy callbacks for every async resource. Hooks like these are easy to get wrong and can add measurable overhead. The Node.js documentation steers application code toward AsyncLocalStorage as the supported interface. Leave createHook to diagnostic tooling.
Request A Request B
│ │
▼ ▼
┌───────────────────────┐ ┌───────────────────────┐
│ storage.run(storeA,fn)│ │ storage.run(storeB,fn)│
└───────────┬───────────┘ └───────────┬───────────┘
│ await, db query │ setTimeout
│ (captures ctx A) │ (captures ctx B)
└──────────────┬───────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ One thread, one event loop: callbacks in run order │
│ 1. A: continuation after await ─▶ getStore()=storeA │
│ 2. B: setTimeout callback ─▶ getStore()=storeB │
│ 3. A: db query callback ─▶ getStore()=storeA │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────┐
│ work scheduled outside run() │─▶ getStore() = undefined
└──────────────────────────────┘
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ AsyncLocalStorage │ │ createHook() │
│ (application code) │ │ (diagnostic tooling only) │
└──────────────┬──────────────┘ └──────────────┬──────────────┘
└───────────────┬────────────────┘
▼
┌──────────────────────────────────────────┐
│ async context tracking, node:async_hooks │
└──────────────────────────────────────────┘
Each continuation captures the async context that was active when it was scheduled, so callbacks from requests A and B interleave on one thread yet each sees its own store, while AsyncLocalStorage and createHook sit on the same runtime context-tracking machinery.
Implementing it: run() at the boundary, getStore() behind an accessor
Create one AsyncLocalStorage instance per concern (request context, tenant, tracing) at module scope and export it from a single module. Separate instances let unrelated libraries set and read their own stores without colliding, and module scope ensures every importer shares the same instance.
Call run(store, callback) at the boundary where a request enters your process: the first middleware, or the top of the server's request handler, wrapping the rest of the chain. Everything invoked inside that callback, including work it schedules asynchronously, sees the store. Code that runs outside the callback does not.
Never call getStore() directly from business code. Put it in a small accessor module that returns a typed value and decides what undefined means. Startup code, cron jobs and tests run outside any request. The accessor should either return a safe default or throw a clear error, rather than failing later with an obscure property-access crash.
Prefer run() over enterWith(). enterWith() replaces the store for the rest of the current synchronous execution and everything that execution schedules, with no defined end point. Call it in the wrong place and the context can leak into unrelated work. It is defensible only when a framework hook gives you no callback to wrap. Even then, keep it inside a function that is already the request's entry point.
| Approach | Adding userId after auth |
Concurrent sub-tasks |
|---|---|---|
| Mutable store object | Assign a property on the shared object | All siblings see each other's writes, so races are possible |
Nested run() |
Run the remaining chain with a copied store that includes userId |
Each scope is isolated, and the outer store is unchanged |
Use nested run() with immutable copies when sub-tasks fan out in parallel. Reserve mutation for write-once fields that are set before any fan-out.
// context.ts: one AsyncLocalStorage instance per concern, created once at module scope
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
type RequestContext = Readonly<{ requestId: string; userId?: string; step?: string }>;
export const requestContext = new AsyncLocalStorage<RequestContext>();
// Accessor: the only place that calls getStore() and decides what undefined means
export function getContext(): RequestContext {
const ctx = requestContext.getStore();
if (!ctx) throw new Error("No request context: called outside a request (startup, cron or test?)");
return ctx;
}
// Lenient variant for code such as logging that must also work outside requests
export const currentUserId = (): string | undefined => requestContext.getStore()?.userId;
type Next = () => void;
// Boundary: run() wraps the rest of the chain, so everything it schedules sees the store
export function contextMiddleware(_req: unknown, _res: unknown, next: Next): void {
requestContext.run({ requestId: randomUUID() }, next);
}
// After auth: copy the store into a nested scope instead of mutating the shared object
export function authMiddleware(_req: unknown, _res: unknown, next: Next): void {
const userId = "user_123"; // placeholder for the result of verifying credentials
requestContext.run({ ...getContext(), userId }, next);
}
// Concurrent sub-tasks: each gets an isolated child scope, and the parent store is unchanged
export async function fanOut(steps: string[], work: (step: string) => Promise<void>): Promise<void> {
await Promise.all(
steps.map((step) => requestContext.run({ ...getContext(), step }, () => work(step)))
);
// Still the parent store here: no sibling's step value leaked into it
console.log(getContext().step); // undefined
}
A module-scoped AsyncLocalStorage instance that business code reads only through an accessor, with run() at the request boundary and nested run() calls taking immutable copies, used both to add userId after auth and to isolate concurrent sub-tasks.
Logging context: correlated log lines without changing call sites
The logger is where this context pays off most visibly. Wrap your structured logger in a small module that exposes the usual info, warn and error methods. On each call, the wrapper reads the store through your accessor and merges requestId, tenantId, traceId and spanId into the log record. If your logger supports child loggers, the wrapper can build one per request at the run() boundary and keep it in the store. Each call then skips the merge and reuses fields that are already bound. Call sites stay unchanged: log.info("invoice created", { invoiceId }) emits a fully correlated line from any depth of the call stack.
What belongs in context
Keep the store small and safe to print:
- Include stable identifiers that make sense on every line: request and trace IDs, tenant, region, and an anonymized user ID if your policy allows it.
- Exclude request bodies, query results and file buffers. The store lives until the last callback of the request finishes, so anything large there stays in memory just as long.
- Exclude secrets, session cookies and auth tokens. A wrapper that serializes the store will write them into every log line. Store a derived principal ID instead of the token.
The payoff
Adding a field means changing the store and the wrapper, not dozens of function signatures. During an incident, one filter on requestId or traceId returns the full path of a single failing request across modules. You no longer have to piece it together from timestamps.
const { AsyncLocalStorage } = require('node:async_hooks');
const { randomUUID } = require('node:crypto');
const http = require('node:http');
const als = new AsyncLocalStorage();
// Build a small, printable context once per request.
// Bodies, cookies and Authorization headers are deliberately left out.
function buildContext(req) {
// W3C traceparent format: version-traceId-spanId-flags
const [, traceId, spanId] = (req.headers['traceparent'] ?? '').split('-');
return {
requestId: req.headers['x-request-id'] ?? randomUUID(),
tenantId: req.headers['x-tenant-id'] ?? 'unknown',
traceId,
spanId,
};
}
// Thin wrapper: call sites pass a message and fields, and context is merged here.
// Context is spread last so a call site cannot overwrite requestId by accident.
function write(level, msg, fields = {}) {
const ctx = als.getStore() ?? {};
const line = { time: new Date().toISOString(), level, msg, ...fields, ...ctx };
process.stdout.write(JSON.stringify(line) + '\n');
}
const log = {
info: (msg, fields) => write('info', msg, fields),
warn: (msg, fields) => write('warn', msg, fields),
error: (msg, fields) => write('error', msg, fields),
};
// Deep business code: no ctx parameter, no signature change.
async function createInvoice() {
await new Promise((r) => setTimeout(r, 10)); // simulated I/O
const invoiceId = 'inv_0001';
log.info('invoice created', { invoiceId });
}
http.createServer((req, res) => {
als.run(buildContext(req), async () => {
await createInvoice();
res.end('ok');
});
}).listen(3000);
This example shows a minimal logger wrapper that reads requestId, tenantId and trace IDs from AsyncLocalStorage on each call, so log.info in deep code emits correlated JSON lines without passing context through function arguments.
Where context is lost and how to restore it
Context is never lost in ways that announce themselves. When the async chain breaks, getStore() does not throw. It returns undefined, or it returns the store of whatever request happened to create the resource. The second case is the dangerous one, because your logs then show a plausible but wrong requestId.
Most breaks come from four patterns:
- Callback and event-emitter libraries. An emitter calls listeners in the context where
emit()runs, not whereon()was registered. If a shared socket or parser emits on behalf of many callers, every listener sees the same context. - Custom thenables. Objects that implement
thenwithout being native promises can schedule their callbacks through their own machinery. Propagation then depends on how that machinery is built. - Connection and worker pools. A pooled connection is often created during the first request that needed it. Its internal callbacks may keep firing in that first request's context long after it finished.
- Queues. Work enqueued in request A and drained by a timer or a consumer loop runs in the consumer's context, unless you capture A's context at enqueue time.
The standard fix is to capture the context where the work originates and re-enter it where the work runs. AsyncResource.bind(fn) returns a function that always executes in the context that was current when bind was called. For an object such as a queue item, create an AsyncResource when the item is enqueued. Then call resource.runInAsyncScope(fn) when the item is processed.
Because failures are silent, test propagation explicitly:
- Start two or more concurrent requests, each with a distinct ID.
- Force interleaving with random delays inside the code under test.
- Assert that every log line or captured store carries the ID of the request that produced it.
Asserting only that an ID is present is not enough, because a stale ID passes that check.
How it saves engineering time
The time savings come from removing repetitive work, not from making code run faster:
- Smaller changes when context grows. Adding a field such as
regionor a new trace attribute means editing the boundary that builds the store and the accessor or logger wrapper that reads it. Without AsyncLocalStorage, the same change means editing every function signature and call site between the edge and the code that needs the value, plus their tests and reviews. - No workarounds at fixed-signature boundaries. Library callbacks, ORM hooks and event handlers whose signatures you do not control can still read the store. You stop writing adapter closures or side-channel maps just to carry a request ID through them.
- Faster incident investigation. When every log line carries
requestId,tenantIdandtraceId, an engineer can filter on one value and see a single request's path across modules. Without that, correlating interleaved lines by timestamp and guesswork is slow and error-prone. - Fewer concurrency bugs to chase. Replacing module-level "current request" variables removes a class of bugs where one request's ID appears in another's logs. These bugs are hard to reproduce because they depend on timing under load.
- Centralized cross-cutting code. Logging, tracing propagation and audit tagging live in one or two modules instead of being spread across business logic. This shortens code review for feature work, since reviewers no longer have to check that context was threaded correctly through each new function.
These savings are offset by the costs covered next: wrapping libraries that lose context, setting up stores in tests, and checking new async boundaries in review. The trade is usually favorable when many layers would otherwise need a ctx parameter. It is less favorable in a small service where only one or two functions need the values.
Performance, memory and cost: measuring overhead in your own service
Context propagation is not free. Each new async resource and each Promise must carry the active store forward, so the cost scales with how many async operations a request creates, not with how long it waits on I/O. A handler that awaits a few database calls barely notices. A service that creates large numbers of small promises per request, through per-item awaits in loops, stream transforms or parser callbacks, feels it more. The size of the overhead also varies across Node.js releases, so a figure measured on someone else's service and runtime tells you little about yours.
Measure with an A/B run
Build one artifact with a flag that replaces run() at the boundary with a direct call. Run the same realistic load test with the flag on and off, keeping hardware, concurrency and payloads fixed. Compare throughput and p50, p95 and p99 latency. Tail percentiles usually show the difference first.
Illustrative estimate (assumed numbers, not a benchmark). Overhead per request ≈ async operations per request × added cost per operation. Assume 200 operations at 0.5 µs each: 200 × 0.5 µs = 100 µs. That is 0.5% of a 20 ms handler but 10% of a 1 ms handler.
Memory retention
Anything referenced from the store stays reachable as long as something holds its context, such as a lingering setInterval, a promise that never settles, or a cached closure. Store IDs and small metadata. Do not store request bodies or connection handles.
Development cost
Wrappers for libraries that lose context grow with the dependency tree. Unit tests must set up a store explicitly, and reviewers must check every new async boundary. Weigh that ongoing work against the plumbing it removes.
When to avoid it, alternatives, and an architecture guideline
Keep anything that changes business behavior out of the store. Database handles, transaction objects, feature-flag evaluations and authorization decisions all determine what a function does, so they belong in its signature. When they hide in AsyncLocalStorage, the store becomes a service locator: a function's real dependencies are invisible at the call site, and a unit test passes or fails depending on whether someone remembered to wrap it in run().
Multi-tenant systems raise the stakes. If a connection pool, cached client or background job captures context from the request that created it, later work can run under the wrong tenant's ID. That failure is silent and looks like correct output. Frameworks that manage their own context, such as dependency-injection scopes or per-request containers, add a second source of truth. Mixing the two couples you to how both propagate.
| Approach | Safety | Testability | Ergonomics | Coupling |
|---|---|---|---|---|
| Explicit arguments | High | High | Verbose | Low |
req mutation |
Medium | Medium | Easy in handlers only | HTTP layer |
| Module globals | Unsafe under concurrency | Low | Easy | High |
| Framework context | High within framework | Medium | Good | Framework |
| AsyncLocalStorage | Medium, depends on boundaries | Needs store setup | Good | Runtime |
Guideline
Use AsyncLocalStorage only for cross-cutting concerns: logging, tracing, request metadata and per-request caches. Put it behind one small module that exposes typed accessors and never exports the storage instance itself. Pass everything business logic depends on as an explicit argument.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nodejs asynclocalstorage, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.