Three calls deep, the code that wants to log a request id no longer has req. The usual answers are both bad: thread req through every signature down to the database layer, or keep the id in a module variable, which is wrong the moment two requests overlap. Node's AsyncLocalStorage (AsyncLocalStorage) instead carries a store along the asynchronous call chain, so any function running for a request can read it. One middleware creates the store and runs the rest of the chain inside it.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
export const context = new AsyncLocalStorage();
export const log = (msg) => console.log(`[${context.getStore()?.requestId ?? '-'}] ${msg}`);
app.use((req, res, next) => {
const store = { requestId: req.get('x-request-id') ?? randomUUID() };
res.set('X-Request-Id', store.requestId);
context.run(store, next); // every later layer runs inside this store
});Two overlapping requests, tagged req-a and req-b by the client, interleave their work and still label every line correctly, although log is called from a data-access function that was never passed req:
[req-b] loading book 2 [req-a] loading book 1 [req-b] loaded book 2 [req-a] loaded book 1
Register this middleware first, before the logger, so even a body-parse failure is logged with an id. Honor an incoming x-request-id — that is how a trace survives across a gateway and several services — but cap its length and strip control characters before it reaches your logs.
Keep the store small: a request id, the authenticated user id, a tenant, a start time. AsyncLocalStorage is cheap in modern Node but not free, and a store holding an open database connection keeps that object alive for the whole request. Correlation IDs builds correlation ids in structured logs on this same context.