Stop Threading Request IDs Through Every Function: AsyncLocalStorage in Node.js
AsyncLocalStorage lets Node.js servers carry request-scoped context — IDs, loggers, tenants — through async chains without threading parameters through every function.
14 Nov 2025, 17:09 UTC

You add a request ID for tracing. A week later, half your function signatures have grown a ctx or reqId parameter, your database helper takes an argument it never uses except to pass along, and every log formatter is one refactor away from losing the ID entirely. This is the problem AsyncLocalStorage solves: it keeps a value attached to an asynchronous execution chain so downstream code can read it without anyone passing it.
The thesis is simple. For request-scoped data — a request ID, a logger, a tenant, a user principal — AsyncLocalStorage is usually a better engineering choice than manual parameter threading, provided you respect its boundaries.
What AsyncLocalStorage actually does
AsyncLocalStorage is part of the built-in async_hooks module. It maintains a store that is visible to the current synchronous execution and to every asynchronous operation spawned from it — promises, async/await, timers, and most I/O callbacks. You create one instance, typically at module scope, and call run(store, callback) to establish a context. Everything inside that callback and its async descendants sees the same store via getStore().
Think of it as a scope that follows the async call graph instead of the lexical one. Two concurrent requests each get their own store, and neither can see the other's, because the context is tied to the execution chain, not to a global.
A worked example: request ID propagation
Here is the pattern in a plain Node.js HTTP server. Run this anywhere Node.js is installed; no extra dependencies are needed. It assumes a recent LTS release — check process.version and confirm require('async_hooks').AsyncLocalStorage exists on your runtime before adopting it.
const http = require('http');
const { AsyncLocalStorage } = require('async_hooks');
const { randomUUID } = require('crypto');
const storage = new AsyncLocalStorage();
function log(message) {
const store = storage.getStore();
const id = store ? store.requestId : 'no-context';
console.log(`[${id}] ${message}`);
}
async function loadUser() {
// No requestId parameter needed here.
await new Promise((r) => setTimeout(r, 10));
log('loaded user');
return { name: 'ada' };
}
const server = http.createServer((req, res) => {
const store = { requestId: randomUUID() };
storage.run(store, async () => {
log('request started');
const user = await loadUser();
log(`serving ${user.name}`);
res.end('ok');
});
});
server.listen(3000);Start it with node server.js, then hit it with curl http://localhost:3000/ a few times, including in parallel. Each log line should carry a distinct request ID, and IDs should never cross between concurrent requests. That second property is the check that matters: if you ever see one request's ID in another's logs, context is leaking, and the usual culprit is the next section's topic.
Note that loadUser and log know nothing about HTTP. That is the payoff: your data layer and logging helpers stay decoupled from the transport, and adding a new field to the context (say, a tenant ID) touches only the middleware that creates the store and the code that reads it — not every signature in between.
Where it breaks or misleads
AsyncLocalStorage is not magic, and three limitations deserve respect.
Worker threads and some native boundaries
Context does not automatically propagate into Worker Threads. If you offload work to a worker, you must pass the relevant values explicitly, for example through workerData or a message. Some native addons and unusual async patterns can also drop context. If a log line shows no-context unexpectedly, that is your diagnostic signal: something executed outside the chain.
Memory retention
The store lives for the whole async context. Storing large objects — a full request body, a big ORM entity — keeps them reachable until the chain completes. Keep the store small: IDs, a logger, a principal object. Treat it like a header, not a suitcase.
enterWith misuse
The enterWith(store) method sets context for the rest of the current execution without scoping it to a callback. It is tempting in middleware frameworks, but used at the wrong boundary it can leak context into unrelated work that happens to share the execution. Prefer run, which has a clear start and end, unless you have verified enterWith behaves correctly in your framework's middleware model.
There is also a performance cost. Async context tracking adds overhead to async resource creation, and the characteristics have changed across Node.js major versions. For most request/response servers this is noise, but if you are writing a hot, high-throughput proxy, benchmark on your target runtime before committing.
When to choose it
The trade-off is indirection for cleanliness. Manual context passing is explicit and greppable — you can see exactly where a value flows — but it taxes every layer. AsyncLocalStorage removes the tax and hides the flow. A reasonable rule: use it for cross-cutting observability concerns (request IDs, tracing spans, log enrichment) where explicit passing adds no design value, and keep explicit parameters for data that is part of a function's actual contract.
To verify your setup end to end, write one test: fire two concurrent requests with distinct IDs, await both, and assert each response's logs contain only its own ID. That single test catches leaks, lost context, and framework integration issues. Adopt it behind that test, keep the store small, and you can delete a lot of plumbing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.