Node AsyncLocalStorage: Request Context Without Passing req
Somebody's payment failed at 3am and the only line in the log was "charge declined". Forty requests were in flight, nothing said which one it belonged to, and the request object was nowhere near the function that logged it. I spent the next month passing a request ID down through every signature on that path, and every new function widened the funnel. Then a colleague showed me AsyncLocalStorage and most of that plumbing disappeared in an afternoon.
What I ended up with is one instance for the process, one run() per request, and a logger that reads the store itself. The request context is then available deep in the call chain, without anybody passing it:
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'
const requestContext = new AsyncLocalStorage()
app.use((req, res, next) => {
const store = { requestId: req.get('x-request-id') ?? randomUUID() }
res.setHeader('x-request-id', store.requestId)
requestContext.run(store, next)
})
function log(msg) {
const store = requestContext.getStore()
console.log(JSON.stringify({ requestId: store?.requestId ?? null, msg }))
}
Everything scheduled inside run() inherits the store, including timers and callbacks that fire much later. Three places surprised me after that, and all three passed code review on the way in.
AsyncLocalStorage isolates the binding, not the object
Months later somebody tidied this into a context module: one exported helper, no per-request object lying around, the store hoisted to module scope.
// context.js
export const store = { requestId: null, userId: null }
export const withContext = (fn) => requestContext.run(store, fn)
Now every request shares one object, and the last writer wins. I ran two overlapping "requests" that write their id, await a tick, then read it back. With a fresh object per run I get [1, 2]. With the shared object I get [2, 2]: the first request's log line carries the second request's ID, and nothing complains. The instance is the reusable part. The store object is not, so build it inside run().
Where getStore() comes back undefined
The store lives in the async chain, not in a global you can read from anywhere, and that is the design. Three spots still caught me. A logger called from a boot-time interval or an exit handler sees nothing, which is what the ?? null in my logger is for. A worker_threads worker sees nothing either. It is a separate isolate with its own heap, so give it its own instance and its own run(), or put the ID in the message you post to it.
The one that cost me an afternoon was a shared resource created at boot. It delivers its results in the boot context no matter which request issued the work:
// created at boot, outside any request
const conn = new EventEmitter()
const pending = []
conn.on('data', (cb) => cb('rows'))
setInterval(() => {
const cb = pending.shift()
if (cb) conn.emit('data', cb)
}, 10).unref()
// inside a request:
pending.push(() => log('rows arrived'))
// measured: requestId is null
The query was issued inside the request, but the timer that fires the callback was created before any request existed, so the chain running it has no store. The same shape shows up with connection pools, shared emitters, queue consumers, anything warmed up at startup. Node's own docs use a worker pool as the example and fix it with AsyncResource.runInAsyncScope.
enterWith is stickier than it looks
run() has an exit. enterWith() does not. It sets the store for the rest of the current synchronous execution and everything scheduled after it, which reads fine until the code calling it is a long-lived async resource. I measured this with an interval that enters a store on its first tick:
let n = 0
setInterval(() => {
n += 1
if (!requestContext.getStore()) requestContext.enterWith({ tick: n })
console.log('tick', n, requestContext.getStore())
}, 10)
// tick 1 sees {"tick":1}
// tick 2 sees {"tick":1}
// tick 3 sees {"tick":1}
// tick 4 sees {"tick":1}
Every later invocation of that callback runs with the first tick's store, permanently. The docs show the same leak with an event emitter, where a store entered inside one listener is still visible after emit() returns. run() gives you a boundary you can see, so use it unless you are writing a library that genuinely cannot wrap a callback.
The fix for the detached callback
When a library wants to hold your callback and call it later, capture the context at registration. Both helpers are static, which I learned the hard way after copying als.bind(fn) out of a tutorial and getting a TypeError:
// inside the request's run():
const onRows = AsyncLocalStorage.bind((rows) => log('rows arrived'))
pool.query('SELECT ...', onRows) // fires later, still carries the store
snapshot() covers the same job when you want to choose the function later, and you call it as runInRequest(fn, ...args). Both were marked stable in Node 22.15 and 23.11, and I verified both on 22.22 before writing this. On Node 24 the constructor takes a defaultValue option that removes most of the undefined-checking, though I have not needed it.
One boundary is worth stating plainly: the store is process-local. An outgoing HTTP call or a queue message carries nothing unless you copy the ID into a header yourself, which is a feature. The other side should not inherit your internals.
Both shapes live in Snippet Ark now, the middleware and the bound callback, because I have written the shared-store version twice. The pool side of that story, why those connections were exhausted in the first place, is in the MySQL connection post.
AsyncLocalStorage earns its keep once you know where it stops. Keep the store small and per-request, wrap the edge with run(), expect undefined outside a request, and bind anything that outlives the request that created it.