AsyncLocalStorage: The Missing Piece Behind Request Context in Node.js
Lessons from building high-scale backend systems
When engineers first encounter AsyncLocalStorage, the reaction is often:
"Why do we need this? Node.js is single-threaded."
It's a reasonable question.
In fact, the misunderstanding comes from assuming that concurrency and threading are the same thing. They are not.
Many production systems use AsyncLocalStorage every day—often indirectly through logging frameworks, observability tools, tracing systems, ORMs, and web frameworks. Yet many engineers never fully understand what problem it solves.
This article explains the production problem that led to AsyncLocalStorage, how it works internally, and why it has become a foundational primitive for modern Node.js applications.
The Production Problem
Imagine you're operating an Order Service.
A request arrives:
POST /orders
The request flows through multiple layers:
HTTP Request
│
▼
Controller
│
▼
Service
│
▼
Repository
│
▼
MongoDB
At the beginning of the request, we generate:
requestId = "req-123"
Every log emitted during this request should contain that identifier:
[req-123] Order creation started
[req-123] Inventory validated
[req-123] Mongo transaction committed
Without this information, debugging production incidents becomes significantly harder.
The challenge is that the request passes through many layers.
The Traditional Approach
The obvious solution is to pass the request ID everywhere.
createOrder(requestId);
validateInventory(requestId);
saveOrder(requestId);
logger.info(requestId, "saved");
This works initially.
As systems grow, additional context appears:
requestId
traceId
tenantId
userId
transaction
Function signatures begin to look like:
function processOrder(
order,
requestId,
tenantId,
traceId,
transaction
)
Business logic becomes polluted with infrastructure concerns.
Engineers often refer to this as "parameter plumbing."
What We Actually Want
Ideally, we would initialize request-scoped context once:
{
requestId: "req-123",
tenantId: "acme",
traceId: "trace-456"
}
and retrieve it from anywhere later:
logger.info("order created");
without passing values through every function call.
This is precisely what AsyncLocalStorage provides.
Understanding the Core Idea
The most important thing to understand is:
AsyncLocalStorage does not store data per thread.
AsyncLocalStorage stores data per asynchronous execution context.
This distinction is critical.
Many engineers carry a mental model from Java or Go where context is often associated with threads.
Node.js works differently.
Why Single-Threaded Does Not Mean Single Request
Consider two concurrent requests.
Request A:
await db.findUser();
Request B:
await db.findOrder();
Execution may look like:
Request A starts
Request A waits for database
Request B starts
Request B waits for database
Database returns result for A
Request A resumes
Database returns result for B
Request B resumes
Only one piece of JavaScript executes at any given moment.
However, multiple asynchronous execution chains are active simultaneously.
Node must know:
When A resumes:
use A's context
When B resumes:
use B's context
This is the problem AsyncLocalStorage solves.
The Internal Model
Under the hood, AsyncLocalStorage is built on top of Node's async_hooks infrastructure.
Node tracks asynchronous resources such as:
Promises
Timeouts
HTTP Requests
TCP Sockets
Database Callbacks
File Operations
Each resource receives an internal identifier.
Conceptually:
HTTP Request
│
▼
Promise
│
▼
Mongo Query
│
▼
Callback
When a resource creates another resource, Node records the parent-child relationship.
Resource #100
│
▼
Resource #101
│
▼
Resource #102
When AsyncLocalStorage starts a context:
als.run(store, callback);
Node associates:
Resource #100
│
▼
Store
Every child resource inherits the same store.
Resource #101 -> Store
Resource #102 -> Store
Later, when execution resumes inside Resource #102:
als.getStore();
Node can determine:
Current Resource = #102
Associated Store = {
requestId: "req-123"
}
and returns the correct context.
The Concurrency Question
One of the most common questions from engineers is:
If there is only one AsyncLocalStorage instance, how do concurrent requests avoid overwriting each other?
The answer is that the storage is not attached to the AsyncLocalStorage object itself.
Think of it like this:
AsyncLocalStorage
│
▼
---------------------------------
Resource #100
Store A
Resource #101
Store A
Resource #200
Store B
Resource #201
Store B
---------------------------------
Request A and Request B share the same AsyncLocalStorage instance.
What differs is the currently executing async resource.
When Request A resumes:
als.getStore();
returns:
{ requestId: "A" }
When Request B resumes:
als.getStore();
returns:
{ requestId: "B" }
The execution context determines which store is visible.
A Production Example
Most teams begin using AsyncLocalStorage for request tracing.
Middleware:
import { AsyncLocalStorage } from "node:async_hooks";
import crypto from "node:crypto";
export const als = new AsyncLocalStorage();
app.use((req, res, next) => {
als.run(
{
requestId: crypto.randomUUID()
},
next
);
});
Logger:
export function log(message) {
const store = als.getStore();
console.log({
requestId: store?.requestId,
message
});
}
Anywhere in the codebase:
log("inventory validated");
Every log automatically receives the request identifier.
How Large Systems Use It
Most production systems use AsyncLocalStorage for infrastructure concerns.
Not business data.
Good examples:
{
requestId,
traceId,
tenantId,
userId,
transaction
}
Poor examples:
{
cartItems,
products,
pricingData
}
The store should contain metadata describing the request, not the application's domain state.
Transaction Propagation
One of the most valuable production use cases is transaction propagation.
Without AsyncLocalStorage:
await createOrder(session);
await reserveInventory(session);
await createInvoice(session);
Every layer must receive the session object.
With AsyncLocalStorage:
als.run(
{
mongoSession: session
},
async () => {
await createOrder();
await reserveInventory();
await createInvoice();
}
);
Repository:
const session =
als.getStore()?.mongoSession;
The transaction becomes automatically available throughout the request lifecycle.
Many ORMs and data-access frameworks implement this pattern internally.
Observability and Distributed Tracing
Modern observability platforms depend heavily on context propagation.
Examples include:
OpenTelemetry
Datadog
New Relic
Elastic APM
Consider:
API Gateway
│
▼
Order Service
│
▼
Payment Service
Each service must know:
traceId
spanId
parentSpanId
Without reliable context propagation, distributed traces become fragmented.
AsyncLocalStorage provides the mechanism Node.js applications use to maintain trace context across asynchronous boundaries.
Why Context Sometimes Appears to "Disappear"
Engineers occasionally encounter:
als.getStore()
returning:
undefined
even though a request context was previously established.
This usually means execution has moved into a different asynchronous context.
Common examples include:
Worker Threads
Child Processes
Queue Workers
BullMQ Consumers
Kafka Consumers
RabbitMQ Consumers
Cron Jobs
These are not descendants of the original request context.
The context cannot be propagated automatically because execution has left the original async resource tree.
In such cases, context must be passed explicitly.
Design Guidance
In production systems, treat AsyncLocalStorage as infrastructure.
Use it for:
✅ Request IDs
✅ Trace IDs
✅ User Context
✅ Tenant Context
✅ Database Sessions
✅ Observability Metadata
Avoid using it as a hidden global state container.
A good rule of thumb is:
If losing the value would break business correctness, it probably should not live in AsyncLocalStorage.
Final Thoughts
The name AsyncLocalStorage often leads engineers toward the wrong mental model.
It is not really about storage.
It is about context propagation.
The feature exists because modern applications need a reliable way to associate metadata with a request while that request moves through a complex graph of asynchronous operations.
Once viewed through that lens, many seemingly unrelated systems begin to make sense:
Structured logging
Distributed tracing
Multi-tenancy
Transaction propagation
Observability platforms
All of them ultimately rely on the same capability:
Carry request-scoped context through asynchronous execution without manually passing it through every function call.
That is the problem AsyncLocalStorage was designed to solve.
