Ambient request context in Node.js
Two runnable examples show why a shared request id fails under concurrency and how AsyncLocalStorage keeps each log attached to the right call.
I was building a small proxy in TypeScript. The handler knew the request id, but the useful log lines happened several functions deeper, after transforms, network calls, and retries.
I wanted every log line to carry the right id without passing a logging context through every function. A module variable looked like the smallest solution.
It was also wrong.
Run the bug first
Save this as broken.mjs:
let currentRequestId
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds))
function log(message) {
console.log(`${message}: request ${currentRequestId}`)
}
async function handleRequest(requestId, delay) {
currentRequestId = requestId
log('started')
await wait(delay)
log('finished')
}
await Promise.all([
handleRequest('A', 20),
handleRequest('B', 0),
])
Run it with Node:
node broken.mjs
The output is the problem in four lines:
started: request A
started: request B
finished: request B
finished: request B
The last line belongs to request A, but it carries B's id.
JavaScript runs one callback at a time on an event loop, while requests still interleave at asynchronous boundaries. Request A reaches await and gives the event loop room to start B. B replaces the shared variable. When A resumes, it reads the latest value.
sequenceDiagram
participant A as Request A
participant G as Shared variable
participant B as Request B
A->>G: set id = A
A-->>A: await
B->>G: set id = B
B->>G: log B
A->>G: resume and log B
Nothing throws. Single-request tests stay green. The log data is simply false under concurrency.
Run the isolated version
Now save this as fixed.mjs:
import { AsyncLocalStorage } from 'node:async_hooks'
const requestStore = new AsyncLocalStorage()
const wait = (milliseconds) =>
new Promise((resolve) => setTimeout(resolve, milliseconds))
function log(message) {
const context = requestStore.getStore()
console.log(`${message}: request ${context?.requestId ?? 'none'}`)
}
async function handleRequest(requestId, delay) {
return requestStore.run({ requestId }, async () => {
log('started')
await wait(delay)
log('finished')
})
}
await Promise.all([
handleRequest('A', 20),
handleRequest('B', 0),
])
Run the second file:
node fixed.mjs
This time the output keeps both requests apart:
started: request A
started: request B
finished: request B
finished: request A
The requests still overlap. Only the storage changed.
Why passing the id is not always the answer
The explicit solution is to add requestId or context to every call between the handler and the logger. It is correct, and for a small call tree it is often the right choice.
The parameter becomes expensive when only the first and last functions care about it:
| Call | Pass context | AsyncLocalStorage |
|---|---|---|
| handler | receives ctx |
calls run(ctx, ...) once |
| build request | accepts and forwards ctx |
unchanged |
| transform | accepts and forwards ctx |
unchanged |
| log | reads ctx parameter |
calls getStore() |
The explicit version changes every intermediate signature. The ambient version establishes one scope at the edge and reads it only where observability needs it.
That distinction matters. I do not use ambient context to hide business inputs. I use it for request ids, trace ids, and similar metadata that describes an operation.
What AsyncLocalStorage does
AsyncLocalStorage associates a value with an asynchronous execution context.
run(value, callback) calls the callback with that value as its store. Promises, timers, and normal asynchronous work created inside the callback inherit the store. getStore() returns the value for the branch that is executing now, or undefined outside a scope.
Each request gets a fresh context object and its own branch:
requestStore
├── run({ requestId: 'A' })
│ ├── handle request A
│ ├── await timer A
│ └── resume with A
└── run({ requestId: 'B' })
├── handle request B
├── await timer B
└── resume with B
This is why the second example works. currentRequestId asks for the last value written by anyone. getStore() asks for the value attached to the current asynchronous branch.
Keep the store behind one small module
The runnable example exposes the mechanics. In an application, I keep the store private and expose two operations: establish a context and log inside it.
// src/observability/log.ts
import { AsyncLocalStorage } from 'node:async_hooks'
export type RequestContext = Readonly<{
requestId: string
tenantId?: string
}>
const requestStore = new AsyncLocalStorage<RequestContext>()
export function withRequestContext<T>(
context: RequestContext,
fn: () => T,
): T {
return requestStore.run(context, fn)
}
export function log(
message: string,
fields: Record<string, unknown> = {},
): void {
const context = requestStore.getStore()
console.log({
...fields,
...(context ?? {}),
message,
})
}
The undefined fallback is intentional. Health checks, tests, and background jobs can log without an active request.
Establish the context at the entry point
The outer handler is the one place where transport-specific context belongs:
export async function proxyHandler(
request: HttpRequest,
invocation: InvocationContext,
): Promise<HttpResponseInit> {
return withRequestContext(
{ requestId: invocation.invocationId },
() => handleRequest(request),
)
}
Everything below it uses the logging module:
async function handleRequest(
request: HttpRequest,
): Promise<HttpResponseInit> {
log('proxy request', { method: request.method, url: request.url })
const response = await callUpstream(request)
log('proxy response', { status: response.status })
return response
}
The request id follows the work without becoming part of handleRequest, callUpstream, or every helper between them.
Prove the two requests stay separate
The useful test overlaps two scopes. A sequential test cannot catch the original bug.
it('keeps concurrent requests apart', async () => {
const output: unknown[] = []
vi.spyOn(console, 'log').mockImplementation((value) => output.push(value))
await Promise.all([
withRequestContext({ requestId: 'A' }, async () => {
await Promise.resolve()
log('A finished')
}),
withRequestContext({ requestId: 'B' }, async () => {
log('B finished')
}),
])
expect(output).toEqual([
{ requestId: 'B', message: 'B finished' },
{ requestId: 'A', message: 'A finished' },
])
})
This protects the only property I care about: request A must never observe request B's context.
Keep business logic out of it
Ambient state is useful because intermediate functions do not need to carry it. The same property becomes dangerous when code silently depends on it.
I keep request and trace metadata in the store for logging and tracing. I do not put authorization decisions, pricing inputs, feature decisions, or transform options there. Those values stay explicit because they change what a function computes.
I also create a fresh, read-only context for every request. AsyncLocalStorage isolates execution branches, but it does not clone the object placed inside them. Reusing and mutating one object would make it shared state again.
The complete idea fits into the two runnable files: a module variable belongs to the process, while AsyncLocalStorage belongs to the asynchronous work started for one request. One run() at the edge and one getStore() at the log call close the gap without changing everything in between.