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.

What keeps a request store alive A request runs inside als.run with a store holding the user, request ID and a loader cache. Promises and queries created in the request capture the context and finish with the response, so the store becomes collectable. A database pool connection first opened during this request also captured the context; because the connection lives for hours in the pool, it keeps this request's store and loader cache alive long after the response. als.run(store, handler) store: user, requestId, loader cache promises, queries end with the response pool connection first opened in this request lives for hours retains the store captured context released: fine

Step-by-Step Fix

  1. Replace module-level request state. Create one AsyncLocalStorage instance and wrap each request handler in als.run(store, next); read request data with als.getStore(). Verification: no module-level maps keyed by request ID remain.
  2. 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.
  3. 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.
  4. 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 via AsyncLocalStorage.snapshot()). Verification: the resource’s captured context is empty.
  5. Prefer run() over enterWith(). 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 no enterWith in request paths.
  6. 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.
Retained stores after traffic stops After a load test and forced garbage collection, a service that lazily opened 40 pool connections and started two refresh timers inside request contexts still retains 42 request stores, each with its loader cache, about 30 megabytes. A service that created them at startup retains zero stores. Request stores alive after traffic stops Lazy creation in requests 42 stores (~30 MB with loader caches) Created at startup 0 stores

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.

Live request stores during and after load During load, the number of live request stores tracks concurrent requests. After traffic stops and GC runs it should drop to zero. If some stores survive, a long-lived resource such as a pooled socket or timer created inside a request captured the context. live stores load test → traffic stops → GC resource created in request keeps stores stores follow requests, then zero

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.