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.
// ❌ 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.
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.
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.
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:
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.
// ✅ 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:
// ❌ 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.
// ❌ 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
AsyncLocalStorageeliminates context parameters — trace IDs, tenant IDs, and user sessions belong in the context store, not in function signatures- One middleware, full coverage — calling
runWithContext()once per request makes context available everywhere in that request's async tree - Throw when the store is missing — silent
undefinedreturns fromgetStore()hide bugs; an explicit error surfaces misconfiguration immediately - 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
- Loggers are the biggest win — a context-aware logger that automatically includes
traceIdandtenantIdon every line requires zero changes to call sites - Function signatures get cleaner — business logic functions stop carrying courier arguments and start reflecting only what they actually operate on



