Skip to main content

Command Palette

Search for a command to run...

AsyncLocalStorage: The Missing Piece Behind Request Context in Node.js

Updated
7 min read

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.