Skip to content

Request Context Without Prop Drilling: AsyncLocalStorage in Node.js

Use AsyncLocalStorage to propagate request-scoped context — trace IDs, user sessions, tenant data — through async call stacks without threading values through every function signature.

Published on May 9, 20264 min read
Node.js async call stack diagram showing request context propagating without explicit parameter threading

Every non-trivial Node.js service eventually hits the same wall: you need a trace ID, tenant ID, or authenticated user available six layers deep in your call stack, inside a utility that has no business knowing about HTTP requests. The instinctive fix is to add a ctx parameter to every function between the request handler and that utility. Three months later, the ctx type has grown to fifteen fields, half your function signatures start with it, and adding a new context value means touching a dozen files.

AsyncLocalStorage — stable since Node 16, available since Node 12 behind a flag — solves this cleanly. It's the Node.js equivalent of thread-local storage: a value bound to an asynchronous execution context that flows automatically through await, setTimeout, Promise.then, and event emitter callbacks without any explicit parameter passing.

The Parameter Threading Problem

Before looking at the solution, it's worth being explicit about the cost of the status quo.

tstypescript
// ❌ Context bleeds into every layer's signature
async function handleCheckout(req: Request, res: Response) {
  const ctx: RequestCtx = { traceId: req.headers["x-trace-id"] as string, userId: req.user.id };
  const result = await processOrder(ctx, req.body.orderId);
  res.json(result);
}
 
async function processOrder(ctx: RequestCtx, orderId: string) {
  await validateInventory(ctx, orderId);   // ctx passed down
  await chargePayment(ctx, orderId);       // ctx passed down
  await sendConfirmation(ctx, orderId);    // ctx passed down
}
 
async function validateInventory(ctx: RequestCtx, orderId: string) {
  // ctx finally consumed — just for one log line
  logger.info("stock check", { traceId: ctx.traceId, userId: ctx.userId });
}

Every function between the handler and the actual consumer of the context becomes a dumb courier. Worse, adding tenantId to RequestCtx now requires touching processOrder, validateInventory, chargePayment, and sendConfirmation — none of which care about it.

How AsyncLocalStorage Works

AsyncLocalStorage creates a store that is automatically inherited by any async operation spawned within a run() call. The runtime tracks which store belongs to which async execution chain and keeps them isolated from each other.

tstypescript
import { AsyncLocalStorage } from "node:async_hooks";
 
const store = new AsyncLocalStorage<{ traceId: string }>();
 
store.run({ traceId: "abc-123" }, async () => {
  await someDeepUtility(); // store is accessible here
});
 
async function someDeepUtility() {
  const ctx = store.getStore();
  console.log(ctx?.traceId); // "abc-123" — no parameter needed
}

Concurrent requests each get their own store instance. There's no shared state, no risk of one request's context leaking into another's.

Building a Type-Safe Context Module

A thin wrapper around AsyncLocalStorage gives you a typed, ergonomic API across your entire codebase.

tstypescript
import { AsyncLocalStorage } from "node:async_hooks";
 
export interface RequestContext {
  traceId: string;
  requestId: string;
  userId?: string;
  tenantId?: string;
}
 
const storage = new AsyncLocalStorage<RequestContext>();
 
export function runWithContext<T>(ctx: RequestContext, fn: () => T): T {
  return storage.run(ctx, fn);
}
 
export function getContext(): RequestContext {
  const ctx = storage.getStore();
  if (!ctx) {
    throw new Error(
      "getContext() called outside a request context. " +
      "Ensure runWithContext() wraps the call chain."
    );
  }
  return ctx;
}
 
export function getContextOrNull(): RequestContext | null {
  return storage.getStore() ?? null;
}

Throwing in getContext() when no store exists is intentional. Silent undefined returns hide the bug; an explicit error surfaces it immediately during development.

Wiring It Into Your Request Pipeline

The right place to call runWithContext is your framework middleware — once per request, before any async work begins.

tstypescript
import { randomUUID } from "node:crypto";
import type { Request, Response, NextFunction } from "express";
import { runWithContext } from "./context.js";
 
export function contextMiddleware(
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const traceId =
    (req.headers["x-trace-id"] as string | undefined) ?? randomUUID();
  const requestId = randomUUID();
 
  // Stamp the trace ID onto the response so clients can correlate logs
  res.setHeader("x-trace-id", traceId);
 
  runWithContext(
    {
      traceId,
      requestId,
      userId: req.user?.id,
      tenantId: req.user?.tenantId,
    },
    () => next(),
  );
}

Register it early in your middleware chain, before any route handlers:

tstypescript
app.use(contextMiddleware);
app.use("/api", router);

Every downstream handler, service, and utility called within that request now has access to the context with zero parameters.

Using Context in Utilities

With the middleware in place, any utility can pull context directly.

tstypescript
// ✅ Logger auto-attaches trace context — no ctx parameter needed
import { getContextOrNull } from "./context.js";
import pino from "pino";
 
const baseLogger = pino({ level: "info" });
 
export const logger = {
  info: (msg: string, data?: Record<string, unknown>) => {
    const ctx = getContextOrNull();
    baseLogger.info({ ...data, ...ctx }, msg);
  },
  error: (msg: string, error: unknown, data?: Record<string, unknown>) => {
    const ctx = getContextOrNull();
    baseLogger.error({ ...data, ...ctx, err: error }, msg);
  },
};

The data access layer benefits equally:

tstypescript
// ❌ Previously required ctx threading just for audit logging
export async function updateUserEmail(
  ctx: RequestContext,
  userId: string,
  email: string,
): Promise<void> { /* ... */ }
 
// ✅ Signature reflects only what the function actually operates on
export async function updateUserEmail(userId: string, email: string): Promise<void> {
  const { traceId, tenantId } = getContext();
  await db.query(
    "UPDATE users SET email = $1 WHERE id = $2 AND tenant_id = $3",
    [email, userId, tenantId],
  );
  await auditLog.record({ event: "email_updated", userId, traceId });
}

The function signature now documents what the function does, not what it has to carry for someone else.

The Detached Promise Pitfall

AsyncLocalStorage propagates context through async operations that are awaited within the run() scope. A fire-and-forget pattern breaks this.

tstypescript
// ❌ Detached promise loses context — getContext() will throw inside sendWelcomeEmail
export async function registerUser(email: string): Promise<User> {
  const user = await createUser(email);
  sendWelcomeEmail(user); // NOT awaited — detached from the request context
  return user;
}
 
// ✅ Capture context before detaching, or use a queue
export async function registerUser(email: string): Promise<User> {
  const user = await createUser(email);
  const ctx = getContext(); // capture while still in scope
 
  setImmediate(() => {
    // Re-enter the context for the detached work
    runWithContext(ctx, () => sendWelcomeEmail(user));
  });
 
  return user;
}

The same rule applies to background jobs scheduled via setTimeout or message queue consumers: they start a fresh execution context. Capture what you need before leaving the request boundary, or re-establish context explicitly when the job runs.

~

For background jobs, a better pattern is storing the context values you need as part of the job payload — { traceId, tenantId, ...jobData } — and calling runWithContext() at the top of the job handler. This makes context explicit at the consumer boundary and survives process restarts.

Key Takeaways

  1. AsyncLocalStorage eliminates context parameters — trace IDs, tenant IDs, and user sessions belong in the context store, not in function signatures
  2. One middleware, full coverage — calling runWithContext() once per request makes context available everywhere in that request's async tree
  3. Throw when the store is missing — silent undefined returns from getStore() hide bugs; an explicit error surfaces misconfiguration immediately
  4. Detached promises break propagation — fire-and-forget async operations leave the original execution context; capture what you need before detaching or re-establish context explicitly
  5. Loggers are the biggest win — a context-aware logger that automatically includes traceId and tenantId on every line requires zero changes to call sites
  6. Function signatures get cleaner — business logic functions stop carrying courier arguments and start reflecting only what they actually operate on
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX