If you have ever passed a requestId or user object through five function signatures just so a logger deep in your code could print it, AsyncLocalStorage in Node.js is the fix. It gives you storage that follows an async call chain, so any code running during a request can read that request's data without it being passed as an argument. In this guide I build a small, typed request-context module for Express and cover the pitfalls that bite in production.
What AsyncLocalStorage solves
Node.js handles many requests at once on one thread, so you cannot keep per-request data in a module-level variable: two overlapping requests would overwrite each other. The usual workaround is passing a ctx parameter everywhere (prop drilling), which pollutes every service and repository signature.
AsyncLocalStorage comes from the built-in node:async_hooks module and is documented under async context tracking in the Node.js docs. You call run(store, callback), and everything that executes inside that callback, including code after await, setTimeout and promise chains, sees the same store through getStore(). It is built in, so no dependency is needed. If you are new to the runtime's concurrency model, start with What is Node.js? first.
Create a typed request context module
Wrap the storage in a small module so the rest of the app never touches AsyncLocalStorage directly.
// src/context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContext {
requestId: string;
userId?: string;
startedAt: number;
}
const storage = new AsyncLocalStorage<RequestContext>();
export function runWithContext<T>(ctx: RequestContext, fn: () => T): T {
return storage.run(ctx, fn);
}
export function getContext(): RequestContext | undefined {
return storage.getStore();
}
export function setUserId(userId: string) {
const ctx = storage.getStore();
if (ctx) ctx.userId = userId;
}
The store is a plain object, so you can mutate it later in the request, for example after authentication resolves the user.
Add the Express middleware
Register the middleware as early as possible. Anything that runs before it will not have a context.
// src/app.ts
import express from 'express';
import { randomUUID } from 'node:crypto';
import { runWithContext } from './context';
const app = express();
app.use((req, res, next) => {
const requestId = req.header('x-request-id') ?? randomUUID();
res.setHeader('x-request-id', requestId);
runWithContext({ requestId, startedAt: Date.now() }, next);
});
Calling next inside run means the rest of the middleware chain and your route handlers execute within the context. Only trust an incoming x-request-id if it comes from your own proxy; otherwise validate its length and format, since it ends up in your logs.
Use the context in logging and services
Now a logger can attach the request ID automatically.
// src/logger.ts
import { getContext } from './context';
export function log(level: 'info' | 'error', msg: string, extra = {}) {
const ctx = getContext();
console.log(JSON.stringify({
level, msg, ...extra,
requestId: ctx?.requestId,
userId: ctx?.userId,
}));
}
If you use Pino, the same idea works with a mixin function that returns the context fields on every log line. A service deep in your code can now call log('info', 'order created') and the line carries the right request ID with no parameter threading. The same pattern works for the auth step in a JWT flow: after verifying the token, call setUserId and every later log includes the user.
How AsyncLocalStorage keeps context across await
Node tracks which async resource (promise, timer, I/O callback) was created while a given store was active, and restores that store when the callback later runs. That is why this works:
app.get('/orders', async (req, res) => {
log('info', 'start'); // requestId A
await db.query('SELECT 1');
log('info', 'after query'); // still requestId A
res.json([]);
});
Two simultaneous requests each keep their own ID across every await. The mechanism has some overhead, since it hooks into async resource creation, but for typical API workloads it is small compared to network and database time. Measure it if you run extremely hot paths.
Common pitfalls where context gets lost
- Middleware ordering: body parsers or auth middleware registered before the context middleware run without a store. Put it first.
- Connection pools and queues: libraries that hand your callback to a pre-existing connection or worker can run it in the context of whoever created the connection, not the caller. If
getStore()returns the wrong or empty value after a particular library call, wrap that callback withAsyncResource.bind(). - Event emitters: listeners run in the context of the code that calls
emit, not the code that calledon. Do not assume the registering request's context. - Fire-and-forget work: a background task started inside a request inherits that store. That is useful for tracing, but if the store holds large objects it keeps them alive until the task finishes. Keep the store small.
- Always handle the undefined case:
getStore()returnsundefinedoutside a run, for example in cron jobs or startup code.
Testing code that depends on the context
Because the context is just a function wrapper, tests are simple: call your code inside runWithContext.
import test from 'node:test';
import assert from 'node:assert/strict';
import { runWithContext, getContext } from './context';
test('context is available inside run', () => {
runWithContext({ requestId: 't1', startedAt: 0 }, () => {
assert.equal(getContext()?.requestId, 't1');
});
});
When not to use AsyncLocalStorage
It is implicit state, so use it for cross-cutting concerns only: request IDs, tracing, locale, the authenticated user for audit logs. Business inputs such as the order to process should remain explicit function arguments. Hidden dependencies make code harder to read and reason about. Likewise, rate-limit counters belong in a shared store like Redis, not in per-request context; see rate limiting a Node.js API with Redis.
Conclusion
AsyncLocalStorage gives you per-request context without prop drilling: one small module, one early middleware, and a logger that reads from it. Start with a request ID in your logs, which pays off the first time you debug a production issue, then extend it to user ID and tracing fields. Keep the store small, register the middleware first, and verify context survives any library that uses its own pools or queues.





