Node.js, part 10: AsyncLocalStorage — request-scoped context without threading it through every call
Part 10from the Node.js series · 24 parts in all
A request arrives, and forty frames deeper a function wants the request id so it can log
it. The alternatives are all unpleasant: pass it as a parameter through every layer, hang it
off an implicit global (which is wrong the moment two requests overlap), or give up and log
nothing. AsyncLocalStorage is the runtime feature that makes it correct.
One store, entered per request
const { AsyncLocalStorage } = require('async_hooks');
const { randomUUID } = require('crypto');
const store = new AsyncLocalStorage();
// Middleware: everything this request triggers runs inside this store,
// including promises and timers started later.
app.use((req, res, next) => {
store.run({ requestId: randomUUID(), userId: req.user?.id }, next);
});
// Anywhere downstream, with nothing passed in:
function log(msg) {
const ctx = store.getStore() || {};
console.log(JSON.stringify({ requestId: ctx.requestId, msg }));
}
The magic is that the context follows the async chain, not the call stack. A
setTimeout, a database callback, a promise three awaits later — all
still see the store that was active when they were created. That is what makes it
safe under concurrency, unlike a module-level variable.
Where it earns its keep
- Log correlation. Every line gets the request id without a logger parameter, which is what makes distributed tracing usable at all.
- Tenant and transaction context. Which customer, which database transaction, which feature flags — read where they are needed.
- Test and job context. A worker pool can carry its own context into the job it runs.
The costs, stated honestly
It is built on async_hooks, historically one of the more expensive corners of
the runtime — fast enough now to use broadly, but not free, so measure if you are in the
hundreds of thousands of requests per second. Two behaviours to internalise: the store is
only visible to code that runs inside run() (a function called
outside the chain sees nothing), and it is deliberately not a general-purpose state manager —
React-like "context" is not what this is. Treat it as a logger/tracer channel that happens to
be readable. Next: cancellation, the other thing Node lacked for a decade.