Request-Scoped State with AsyncLocalStorage
Your server keeps per-request data — the user, a request ID, a data-loader cache — in module-level variables and it both leaks between requests and grows with traffic; or you already use AsyncLocalStorage and heap snapshots show old request contexts still alive hours later. This guide from SSR Heap Exhaustion and Per-Request Memory, part of Node.js Server-Side Memory Management, explains how AsyncLocalStorage scopes state to a request, why a store can outlive its request, and how to keep request memory bounded.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Module-level maps keyed by request ID grow | Entries not removed when requests end | Move request state into an AsyncLocalStorage store |
State released with the request |
| Old request stores retained for hours | A long-lived async resource was created inside the request context | Create long-lived resources outside request contexts | Stores collected after response |
| Memory per request higher than expected | Stores hold large objects (bodies, loader caches, rendered HTML) | Keep stores small; drop large fields after use | Lower retained size per request |
| Data from one user visible in another request | Shared module state instead of scoped state | Use per-request stores for user-specific data | Isolation and bounded memory |
enterWith() state leaks into unrelated work |
enterWith sets context for the rest of the synchronous execution and its continuations |
Prefer run() with a callback |
Context ends where the callback ends |
Root Cause: Context Propagates to Everything Created Inside It
Servers need request-specific data deep in the call stack: the authenticated user, a correlation ID for logs, a per-request cache to de-duplicate database calls during rendering. Storing it in module-level variables is wrong twice: concurrent requests overwrite each other’s data, and maps keyed by request ID grow unless every code path deletes its entry — the pattern in cross-request state pollution in SSR singletons.
AsyncLocalStorage solves both. als.run(store, fn) executes fn with store as the current context, and Node’s async-context machinery propagates that context to every asynchronous continuation created inside: promises, awaits, timers, I/O callbacks, event emitters bound to the context. Anywhere in that tree, als.getStore() returns the request’s store. When the request finishes and nothing references the store, it is garbage like any other object.
The catch is in “created inside”. An async resource captures the current context when it is created, and keeps a reference to it for as long as the resource lives. Short-lived resources — the promises of this request’s database queries — are fine. Long-lived ones are not: a setInterval started during a request, a pooled database connection or HTTP agent socket first opened while handling a request, a cache refresher scheduled lazily by the first request, an event listener registered on a long-lived emitter. Each captures the store of whichever request happened to create it and retains it — with everything it references, such as the user object, request body or a per-request cache of rendered data — for the resource’s whole lifetime. In heap snapshots this appears as an old store object whose retainer path runs through an async resource or timer rather than through any request. The fix is to create long-lived resources outside request contexts (at startup), or explicitly run their creation outside the context.
Store size matters too: every concurrent request holds its store, so memory scales with concurrency × store size. Keeping stores to small, request-specific values — IDs, the user, a bounded loader cache — keeps that product modest, a concern at the heart of caching vs memory bloat in SSR data layers.
Step-by-Step Fix
- Replace module-level request state. Create one
AsyncLocalStorageinstance and wrap each request handler inals.run(store, next); read request data withals.getStore(). Verification: no module-level maps keyed by request ID remain. - Keep stores small. Put identifiers, the authenticated user and small per-request caches in the store; keep request bodies and rendered output as local variables. Verification: a snapshot of a store object shows a retained size in kilobytes.
- Create long-lived resources at startup. Initialise database pools, HTTP agents, schedulers and cache refreshers before the server starts accepting requests. Verification: pool connections are created outside any request context.
- Escape the context for lazy long-lived work. Where something long-lived must be created lazily, run its creation with
als.exit(() => …)(or a snapshot taken at startup viaAsyncLocalStorage.snapshot()). Verification: the resource’s captured context is empty. - Prefer
run()overenterWith().run()scopes the store to a callback;enterWith()changes the current context for the rest of the synchronous execution and its continuations, which is easy to leak. Verification: code search finds noenterWithin request paths. - Verify release after load. Run a load test, stop traffic, force GC and take a heap snapshot; search for the store class or a distinctive field. Verification: no stores remain once requests have completed.
Command and Code Reference
Use case: request context middleware with a small store.
// context.js
const { AsyncLocalStorage } = require('node:async_hooks');
const als = new AsyncLocalStorage();
function requestContext(req, res, next) {
const store = {
requestId: req.headers['x-request-id'] ?? crypto.randomUUID(),
userId: req.user?.id ?? null,
loader: new Map(), // per-request de-dupe cache; dies with the request
};
als.run(store, next); // everything async inside sees this store
}
const ctx = () => als.getStore();
module.exports = { als, requestContext, ctx };
Use case: initialise long-lived resources outside any request.
// server.js
const { als } = require('./context');
async function main() {
const pool = createPool({ max: 20 });
await pool.warmUp(); // connections opened here capture no request context
startCacheRefresher(pool); // intervals started at startup, not lazily
app.use(requestContext);
app.listen(3000);
}
// If something truly must be created lazily, detach it from the request context:
function getReportClient() {
return als.exit(() => (reportClient ??= createReportClient())); // no store captured
}
Verification and Regression Prevention
Confirm two properties with snapshots after a load test: during load, the number of live stores tracks concurrent requests; after traffic stops and GC runs, it drops to zero. Any surviving store’s retainer path shows which long-lived resource captured it. Measure retained size per store to keep the concurrency × size product within your memory budget.
Prevent regressions with a lint or review rule against module-level request maps and against enterWith in request code, a startup checklist for pools, agents and schedulers, and a soak test that asserts no stores survive idle periods. For the rendering side of per-request memory, see streaming SSR memory with renderToPipeableStream.
Edge Cases and Gotchas
Libraries that create resources lazily
Some clients create connections or background timers on first use. If first use happens inside a request, they capture that request’s context. Warm them up at startup, or wrap their first use in als.exit().
Event emitters and bound handlers
Handlers registered on long-lived emitters during a request run with the context of their registration in some patterns (for example when bound with AsyncResource.bind). Avoid registering long-lived handlers inside requests.
Performance cost
Async context propagation has some overhead, which has decreased in recent Node versions. It is usually negligible compared with the memory and correctness benefits; measure if you run extremely high request rates.
Framework integrations
Many frameworks provide request context helpers built on AsyncLocalStorage. Check what they put in the store — some keep the entire request object — and avoid adding large values of your own.
Frequently Asked Questions
Does AsyncLocalStorage cause memory leaks?
Not by itself. A store is retained only while something in its async tree is alive. Leaks appear when long-lived resources — timers, pooled connections, background tasks — are created inside a request context and keep that request’s store for their lifetime.
What should I put in an AsyncLocalStorage store?
Small, request-specific values: request and trace IDs, the authenticated user or its ID, feature flags, and bounded per-request caches. Keep large payloads as local variables so they are released as soon as they are no longer needed.
What is the difference between run() and enterWith()?
run(store, fn) makes the store current only for fn and the async work it creates. enterWith(store) makes it current for the remainder of the current synchronous execution and its continuations, which is harder to reason about and easier to leak. Prefer run().
How do I stop a background task from capturing a request context?
Create it at startup, or wrap its creation in als.exit(() => …) so it is created with no store. Tools like AsyncLocalStorage.snapshot() also let you capture a clean context once and run later work inside it.
How can I find retained stores in a heap snapshot?
Give stores a recognisable shape (a class, or a distinctive property name), take a snapshot after traffic stops, and search for it. The retainer path of any surviving store shows the async resource that captured it.
Is AsyncLocalStorage available in other runtimes?
It is part of Node.js and also supported, at least partially, by several other server runtimes for compatibility. The same retention rules apply wherever async context propagation is implemented.
Related
- SSR Heap Exhaustion and Per-Request Memory — the parent topic
- Cross-Request State Pollution in SSR Singletons — the problem request scoping solves
- Node.js Memory in AWS Lambda Across Warm Invocations — module state across invocations
- Node.js Server-Side Memory Management — the section overview