Process-global singletons break the moment two concurrent requests share state. AsyncLocalStorage gives you request-scoped context that propagates through arbitrary async boundaries — including Promise.all fan-out — without changing any function signatures.
Process-global singletons are the silent killer of concurrent Node.js applications.
You have a flag object — policyFlags, requestContext, userSession — that controls behavior for the current operation. You set it at the start of a request. Everything works fine until two requests run concurrently. Now request A sets the flag, request B reads it, and something downstream behaves incorrectly for request B because it inherited request A’s state.
The usual fix is to thread the context object through every function call as an explicit parameter. This works, but it’s invasive — every function signature changes, every call site changes, and any code path that doesn’t thread the context correctly becomes a subtle bug.
AsyncLocalStorage is the better solution.
AsyncLocalStorage (built into Node.js async_hooks) lets you associate a value with the current async execution context. When you spawn async work from within a context — await, Promise.all, setTimeout, event handlers — the child inherits the same context automatically.
import { AsyncLocalStorage } from 'node:async_hooks';
const store = new AsyncLocalStorage<RequestContext>();
// Run a function with a specific context value
store.run(context, async () => {
await doSomething(); // inherits context
await Promise.all([ // all children inherit context
doA(),
doB(),
]);
});
// Read the context anywhere in the call tree
function doA() {
const ctx = store.getStore(); // the same context set above
}
No parameter threading. No changes to function signatures. The context propagates through the entire async call tree automatically.
Consider an agentic system that processes multiple tickets concurrently. Each ticket run has policy flags that control behavior: should the PR be created as a draft? Is PR creation blocked entirely? These flags are ticket-specific — they must not leak between tickets.
With a process-global singleton:
// ❌ Global singleton — breaks with concurrent tickets
export const policyFlags = new PolicyFlags();
// Ticket A run:
policyFlags.enableBlockPrCreation('blast radius too high');
// Ticket B run (concurrent):
policyFlags.shouldBlockPrCreation(); // true — wrong! Ticket A's flag leaked
With AsyncLocalStorage:
const policyFlagsStore = new AsyncLocalStorage<PolicyFlags>();
export function runWithPolicyFlags<T>(
flags: PolicyFlags,
fn: () => T | Promise<T>
): T | Promise<T> {
return policyFlagsStore.run(flags, fn);
}
export function currentPolicyFlags(): PolicyFlags {
// Falls back to a default singleton when called outside any scope
return policyFlagsStore.getStore() ?? defaultPolicyFlags;
}
Now each ticket run gets its own isolated PolicyFlags instance:
// Ticket A and B running concurrently:
await Promise.all([
runWithPolicyFlags(new PolicyFlags(), () => processTicket('TICKET-A')),
runWithPolicyFlags(new PolicyFlags(), () => processTicket('TICKET-B')),
]);
// Each sees only its own flags. No leakage.
Explicit threading works. It’s also O(n) invasive — every function in the call chain needs to accept and forward the context parameter, including tool implementations, sub-agent runners, and utility functions that currently have no awareness of request scope.
ALS adds zero changes to the Tool interface. Every tool that calls currentPolicyFlags() transparently sees the right flags for its enclosing ticket run. A tool implemented before multi-tenancy was a concern continues to work correctly without modification.
The failure mode ALS explicitly accepts: if you launch a “detached” async task with void fireAndForget() that outlives the scope, ALS propagates correctly into it. This is usually what you want. If you don’t want it, use AsyncResource.bind() to explicitly break the propagation.
Introducing ALS doesn’t require a big-bang migration. The pattern is additive:
AsyncLocalStorage storerunWithPolicyFlags(flags, fn) for new multi-tenant code pathscurrentPolicyFlags() that falls back to the old singleton when no scope is activeLegacy code paths that never call runWithPolicyFlags continue using the process-global singleton exactly as before. New code paths that call runWithPolicyFlags get isolation. You can migrate incrementally.
AsyncLocalStorage is the right tool for any value that is:
Request IDs for distributed tracing. User context for multi-tenant APIs. Database connection selection for per-tenant databases. Configuration overrides for A/B tests.
The pattern is always the same: run(value, fn) at the entry point, getStore() anywhere downstream. The async runtime handles the rest.