A Node.js server handles many requests on one thread, interleaving them at every await. That’s great for throughput and awkward for logging. When a database call fails deep inside a repository function, you want the log line to say which request it belonged to, and that repository function has no idea. You can thread a requestId parameter through every call, or you can let the runtime carry it for you. AsyncLocalStorage, from node:async_hooks, is the runtime doing it for you, and it’s stable in Node 22 and 24.
The interview version of this topic usually goes: “How would you get a correlation ID into every log line without passing it everywhere?” A good answer explains why the obvious shortcut fails, shows run, and then knows where the context quietly goes missing.
The shortcut that mixes up requests
The first idea people reach for is a module-level variable. Middleware sets currentRequestId at the start of the request, and the logger reads it. With one request at a time this works perfectly, which is exactly why it survives local testing.
Here are two overlapping requests. req-1 starts first but its “database call” takes 30 ms; req-2 starts second and finishes in 10 ms. The logger prints both the AsyncLocalStorage value and the global so you can compare them line by line:
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout as sleep } from 'node:timers/promises';
const als = new AsyncLocalStorage();
let currentRequestId; // the broken version
function log(msg) {
const store = als.getStore();
console.log(`[als=${store?.requestId ?? '-'} global=${currentRequestId}] ${msg}`);
}
async function loadUser(delay) {
await sleep(delay);
log('loaded user');
}
async function handle(requestId, delay) {
currentRequestId = requestId;
await als.run({ requestId }, async () => {
log('start');
await loadUser(delay);
log('done');
});
}
await Promise.all([handle('req-1', 30), handle('req-2', 10)]);
log('outside any request');Running it on Node 22.23:
[als=req-1 global=req-1] start
[als=req-2 global=req-2] start
[als=req-2 global=req-2] loaded user
[als=req-2 global=req-2] done
[als=req-1 global=req-2] loaded user
[als=req-1 global=req-2] done
[als=- global=req-2] outside any requestLook at the fifth line. req-1 resumed after its sleep, but by then req-2 had overwritten the global, so the global claims the work belongs to req-2. In production that’s the worst kind of bug: the logs are confidently wrong, and an engineer chasing a customer complaint follows the wrong request. The AsyncLocalStorage column is right on every line, and outside any request getStore() returns undefined, which the logger shows as -.
What run actually does
als.run(store, callback) calls callback synchronously with store as the active value. Any asynchronous work started inside that callback (promise continuations, timers, process.nextTick, I/O callbacks from core modules) remembers the store that was active when it was created, and gets it back when it runs. Two concurrent run calls don’t compete for one slot. Each async chain carries its own reference.
Some points interviewers like to probe here:
- The store is an object reference, not a copy. If you put
{ requestId, user: null }in the store and later setstore.user = userafter authentication, every latergetStore()in that request sees the user. That’s handy. It also means you shouldn’t share one store object between requests. - The callback’s return value comes back from
run. If it’s async,runreturns the promise, soawait als.run(...)waits for the request’s work. - There’s no inheritance across
runcalls. A nestedrunreplaces the store for its callback and restores the outer one afterwards. If you want to add a field for a sub-operation, build a new object:als.run({ ...als.getStore(), step: 'billing' }, fn).
In an HTTP server the pattern is a few lines at the edge:
import { randomUUID } from 'node:crypto';
server.on('request', (req, res) => {
const incoming = req.headers['x-request-id'];
const requestId = /^[\w-]{1,64}$/.test(incoming ?? '') ? incoming : randomUUID();
als.run({ requestId }, () => app(req, res));
});The validation isn’t decoration. A client-supplied header ends up in every log line, so cap its length and character set before trusting it, or a caller can inject fake log entries or bloat your log storage.
Where context gets lost
AsyncLocalStorage follows the chain of async resources. It doesn’t follow the request in any semantic sense. So the failures happen when your callback is executed by some other chain, typically a queue or pool that was set up by a different request.
Here’s a tiny batcher of the kind you find inside database drivers, DataLoader-style libraries and metrics flushers. The first caller starts a timer; later callers just push onto the array; the timer runs everything:
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
const pending = [];
let timer = null;
function enqueue(cb, { bind = false } = {}) {
pending.push(bind ? AsyncLocalStorage.bind(cb) : cb);
timer ??= setTimeout(() => {
timer = null;
for (const fn of pending.splice(0)) fn();
}, 5);
}
for (const bind of [false, true]) {
await new Promise((done) => {
let left = 2;
for (const id of ['req-A', 'req-B']) {
als.run({ id }, () => enqueue(() => {
console.log(`bind=${bind} queued by ${id}, store says ${als.getStore()?.id}`);
if (--left === 0) done();
}, { bind }));
}
});
}bind=false queued by req-A, store says req-A
bind=false queued by req-B, store says req-A
bind=true queued by req-A, store says req-A
bind=true queued by req-B, store says req-BThe second line is the bug. The callback queued by req-B ran inside the timer created during req-A, so it saw req-A’s store. No error, no warning, just a wrong ID. AsyncLocalStorage.bind(cb) captures the context that’s active when the callback is queued and restores it when the callback runs, which is why the last two lines are correct.
The same shape shows up with EventEmitter. A listener runs in the context of whoever calls emit, not whoever called on. If a shared emitter fires from a background connection, the listener sees that connection’s context or none at all. AsyncResource.bind(listener) from the same module wraps a listener to keep the context it was registered in.
When context disappears in a real app, it’s almost always one of these: a callback-based library with its own internal queue, a custom thenable that isn’t a native promise, or a pool that reuses objects across requests. Find the place where your callback is stored and invoked later, and bind it there.
enterWith and why it’s rarely the answer
als.enterWith(store) sets the store for the rest of the current synchronous execution and everything it schedules, with no callback to scope it. Some frameworks use it in middleware because they can’t wrap the downstream handler. The danger is that nothing ends it:
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
function middleware(id) { als.enterWith({ id }); }
middleware('req-1');
console.log('after enterWith:', als.getStore());
setTimeout(() => console.log('unrelated timer later:', als.getStore()), 1);after enterWith: { id: 'req-1' }
unrelated timer later: { id: 'req-1' }The timer has nothing to do with req-1, but it inherited the store because it was scheduled after enterWith in the same synchronous run. Use run whenever you control the call that starts the work.
Cost and memory
Every async resource created inside a context holds a reference to the store until it finishes. If the store contains the full request object, a body buffer or a database connection, a long-lived timer or a forgotten interval started during the request keeps all of it alive. Keep the store small: an ID, a user ID, maybe a tenant and a start time. Log enrichment needs nothing more.
On performance, modern Node versions implement AsyncLocalStorage with a much cheaper mechanism than the old async_hooks callbacks, and Node 24 builds it on AsyncContextFrame by default. It’s cheap enough that OpenTelemetry and most structured loggers rely on it. The old advice to avoid it for overhead reasons dates from early versions.
Interview practice
You add AsyncLocalStorage-based request IDs to a service. Most logs are now correct, but log lines from the database layer show either no request ID or the ID of a different request. HTTP handlers and your own async functions are fine. What’s happening and how do you fix it?
Answer
The pattern (correct in your own code, wrong or missing inside one library) points at a callback queue inside the database client. The driver probably queues query callbacks while it waits for a pooled connection, and invokes them from the connection’s socket events. Those events belong to the async chain that created the connection, which might be an earlier request or startup code with no store at all. So the callback either sees another request’s ID or undefined.
To confirm, log als.getStore() right before calling the driver and inside its callback, with two overlapping requests. If the ID changes across that boundary, that’s the leak. Fixes, in order of preference: upgrade the driver or use its promise API, since native promises propagate context correctly; wrap the callback with AsyncLocalStorage.bind (or AsyncResource.bind) at the point you hand it to the driver; or, as a last resort, read the ID before the call and pass it explicitly into the log line for that layer. I’d also add a test with two overlapping requests that finish in reverse order, since a single-request test can’t catch this.