Node.js AsyncLocalStorage in 2026: Context Without Pain
AsyncLocalStorage has quietly become the most important Node.js API you have never had to teach a junior developer about. Built on top of the stable node:async_hooks subsystem since v13.10, it solves the single problem that haunted every serious Node.js codebase from 2010 to 2019: how do you carry a request-scoped value — a request ID, a tenant ID, an authenticated user — through a chain of async functions without dragging it through every single argument? In 2026 the answer is settled. You use AsyncLocalStorage, you use it sparingly, and you instrument it correctly.
This guide is the playbook we hand to every senior Node.js engineer we place. It covers how AsyncLocalStorage actually works under the hood, the four production patterns that pay for themselves on day one, the failure modes that bite under load, and the benchmarks that show the overhead has finally dropped below the threshold where anyone should care.
What AsyncLocalStorage Actually Is
AsyncLocalStorage is a class exported from node:async_hooks. You create one instance per category of state you want to propagate (you almost always need exactly one) and you call .run(store, callback) to enter a scope. Inside that callback — and inside every async function awaited from that callback, transitively — calls to .getStore() return the same store object. When the outermost callback resolves, the scope ends and the store becomes inaccessible from new async chains.
Why not just use a global variable?
Globals work in single-threaded synchronous code. Node.js is single-threaded but not synchronous. Between the time an HTTP handler reads a global and the time it finishes its database query, hundreds of other requests have already mutated that global. AsyncLocalStorage gives every concurrent async chain its own snapshot of the store, automatically, with no developer intervention.
Why not just pass req everywhere?
Threading req through every service, repository, and helper turns a clean layered architecture into a spaghetti of redundant arguments. It also makes utility functions (loggers, metrics, audit helpers) impossible to call from anywhere that does not have direct access to the request — which is most places.

Setting Up AsyncLocalStorage in 60 Seconds
Every codebase you adopt this in needs exactly two files: a context module that owns the AsyncLocalStorage instance, and a middleware that wraps each request in a fresh store. Everything else — loggers, ORMs, metrics — reads from the context module.
// src/context.js — owns the single shared store
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
export const requestContext = new AsyncLocalStorage();
export function runWithContext(initial, fn) {
const store = new Map(Object.entries(initial));
if (!store.has('requestId')) store.set('requestId', randomUUID());
return requestContext.run(store, fn);
}
export function getContext(key) {
const store = requestContext.getStore();
return store ? store.get(key) : undefined;
}
export function setContext(key, value) {
const store = requestContext.getStore();
if (store) store.set(key, value);
}// src/middleware/context.js — bind one store per HTTP request
import { runWithContext } from '../context.js';
export function contextMiddleware(req, res, next) {
runWithContext({
requestId: req.headers['x-request-id'],
traceId: req.headers['traceparent'],
tenantId: req.headers['x-tenant-id'],
userId: req.auth?.sub
}, () => next());
}The Four Production Patterns You Will Actually Use
Pattern 1 — Request-scoped logger
This is the gateway drug. Replace your global logger with one that automatically attaches the current requestId, userId, and tenantId to every log line. Pino makes this trivial with child loggers; any structured logger that supports per-call metadata will do.
// src/logger.js
import pino from 'pino';
import { requestContext } from './context.js';
const base = pino({ level: process.env.LOG_LEVEL ?? 'info' });
function mixin() {
const store = requestContext.getStore();
if (!store) return {};
return {
requestId: store.get('requestId'),
traceId: store.get('traceId'),
tenantId: store.get('tenantId'),
userId: store.get('userId')
};
}
export const logger = pino({ ...base.bindings(), mixin });
// every logger.info(...) anywhere in the codebase gets request context for freePattern 2 — Multi-tenant DB scoping
If you run multi-tenant SaaS, every query needs a tenant filter. Pull it from context inside your repository layer, never from method arguments. The handler is unaware of tenancy; the database boundary enforces it.
// src/repositories/orders.js
import { db } from '../db.js';
import { getContext } from '../context.js';
export async function listOrders() {
const tenantId = getContext('tenantId');
if (!tenantId) throw new Error('No tenant in context');
return db.query('SELECT * FROM orders WHERE tenant_id = $1', [tenantId]);
}Pattern 3 — Trace propagation for OpenTelemetry
OpenTelemetry uses AsyncLocalStorage under the hood to carry the current span across awaits. If you already have OpenTelemetry instrumentation you don't need to do anything — but understanding ALS makes it obvious why a forgotten setImmediate or a callback pulled out of a non-promise API can lose your span. Pattern 3 is using ALS yourself for the few non-OTEL signals (audit logs, billing meters) that need the same trace ID.
Pattern 4 — Per-request feature flag cache
Feature flag SDKs make a remote call per evaluation. Resolve flags once at the start of a request, stash the resolved values in ALS, and let every downstream consumer read them from getContext('flags'). This eliminates inconsistent flag evaluation within a single request — a subtle bug that produces user-visible weirdness when two parts of the same endpoint disagree about whether a feature is on.
Hire Pre-Vetted Node.js Developers
Skip the months-long search. Our exclusive talent network has senior Node.js experts ready to join your team in 48 hours.

Performance: Is It Actually Free in 2026?
Almost. AsyncLocalStorage relies on AsyncResource hooks under the hood, and every Promise creation pays a small per-microtask cost when at least one ALS instance exists in the process. In Node 18 LTS that cost was measurable — about 8–12% throughput hit on a tight loop. In Node 22 LTS, after the V8 fast-path work for async_hooks landed, the overhead is consistently under 4% on real HTTP workloads and effectively zero when the store is empty.
The practical advice has not changed since 2022: create exactly one AsyncLocalStorage instance per process, store a Map or a plain object inside it, and do not nest multiple .run() calls deeper than necessary. The instance count matters more than the store size.
The Five Ways It Goes Wrong in Production
1. Worker threads do not share the store
Each Worker has its own async_hooks instance. Posting a message to a worker does not carry the ALS store. If you need request context inside a Worker, serialize the relevant fields into the message payload and re-establish the store on the other side.
2. Long-lived connections capture stale context
If you create a database connection inside als.run() and reuse it across requests, queries against that connection may see whatever store was active when the connection was made. Always acquire connections from a pool inside the request scope, never store an active connection in a module-level variable.
3. Callbacks from C++ addons
A few native modules invoke callbacks without going through the async hooks runtime. If you see getStore() returning undefined inside the callback of a native library, wrap the registration in AsyncResource.bind() at the boundary.
4. Test isolation pollution
Jest and Vitest reuse the same process across test files. A test that calls als.run() and never returns from it (because of an unhandled rejection) leaves the store hanging. Subsequent tests then run inside that store. Always pair als.run() with try/finally if you do anything weird with control flow.
5. Snapshot mutation
Storing a plain object in ALS makes it tempting to set context fields from anywhere. If two concurrent async chains were forked from the same parent scope they will share the same Map by reference. Treat the store as append-only for the request lifetime — never write a value that another concurrent request would want to read differently.
Hiring Engineers Who Get Context Propagation Right
AsyncLocalStorage is one of those topics that separates engineers who have shipped a multi-tenant Node.js system from engineers who have only read about one. The mistakes above don't show up in localhost smoke tests; they surface at 30 RPS with leaked tenant IDs in your audit log.
In a senior Node.js technical interview, ask the candidate to design the context-propagation layer for a multi-tenant API. A strong answer mentions AsyncLocalStorage by name, distinguishes between the store and the value, explains EventEmitter binding, and acknowledges the worker-thread boundary. A weak answer reaches for cls-hooked, suggests a singleton plus locks, or proposes passing req into every function.
Hire Expert Node.js Developers — Ready in 48 Hours
Getting AsyncLocalStorage right is a small slice of a much bigger picture — observability, multi-tenancy, async safety, and clean layering all sit on top of it. HireNodeJS.com specialises exclusively in Node.js talent: every developer is pre-vetted on production patterns like context propagation, distributed tracing, event-driven architecture, and high-throughput API design.
Unlike generalist platforms, our curated pool means you speak only to engineers who live and breathe Node.js. Most clients have their first developer working within 48 hours of getting in touch. Engagements start as short-term contracts and can convert to full-time hires with zero placement fee.
Conclusion
AsyncLocalStorage is the quiet workhorse of every well-instrumented Node.js system in 2026. Set it up once, expose it through a tiny context module, and watch your logs, traces, audit trails, and multi-tenant queries gain a layer of correctness that no manual req-threading could ever match — at an overhead that is now indistinguishable from zero on Node 22 LTS.
If you remember nothing else from this guide, remember this: one ALS instance per process, one middleware to bind the store, and a try/finally around any code that does interesting things with the scope. Everything else is a special case.
Frequently Asked Questions
What is AsyncLocalStorage in Node.js?
AsyncLocalStorage is a class from the node:async_hooks module that lets you carry a value through asynchronous callbacks without passing it explicitly. Inside als.run(store, fn) every awaited function downstream sees the same store via als.getStore(), giving you request-scoped state without globals.
Is AsyncLocalStorage production ready in 2026?
Yes. AsyncLocalStorage has been stable since Node.js 16.4 and the underlying async_hooks subsystem received fast-path optimisations in Node 22 LTS. Overhead on real HTTP workloads is under 4% and effectively zero when the store is unused. It is the default mechanism in OpenTelemetry, Sentry, and most modern Node.js frameworks.
AsyncLocalStorage vs cls-hooked — which should I use?
Use AsyncLocalStorage. cls-hooked is a community wrapper over the same async_hooks primitive, written before the native API stabilised. It carries 4–5x more per-request overhead and adds a runtime dependency for no benefit. New code should never reach for cls-hooked.
Does AsyncLocalStorage work with worker threads?
Each worker thread has its own async_hooks subsystem and its own AsyncLocalStorage instances. Posting a message to a worker does not carry the parent's store. If you need context inside a worker, serialise the relevant fields into the message and re-create a store on the worker side.
Can I use AsyncLocalStorage with Express, Fastify, and NestJS?
Yes for all three. Express and Fastify expose a middleware boundary where you call als.run() per request. NestJS has built-in helpers like ClsModule and direct integration with @nestjs/core's request scope. The setup is a one-time five-line middleware in every case.
What is the performance overhead of AsyncLocalStorage?
On Node 18 LTS the overhead was 8–12% on tight async loops. On Node 22 LTS, after V8 fast-path optimisations for async_hooks landed, real-world HTTP workloads see under 4% overhead — usually 1–3% when measured with autocannon. For practical purposes you should treat it as free.
Vivek Singh is the founder of Witarist and HireNodeJS.com — a platform connecting companies with pre-vetted Node.js developers. With years of experience scaling engineering teams, Vivek shares insights on hiring, tech talent, and building with Node.js.
Need Senior Node.js Engineers Who Ship Observable, Multi-Tenant APIs?
HireNodeJS connects you with pre-vetted senior Node.js engineers available within 48 hours. Every developer is vetted on async patterns, observability, and production multi-tenancy — no recruiter fees, no lengthy screening.
