You're building a Node.js API. You want every log line to include the requestId of the request that triggered it. You want your database query logger to know which HTTP request caused the query. You want error tracking to correlate errors to users.
The naive solution: pass req (or a context object derived from it) into every function call. Every service, every utility, every helper. It works, but it poisons your function signatures and couples your business logic to your HTTP layer.
AsyncLocalStorage solves this cleanly. It's built into Node.js, it's stable, and it's exactly the right tool for request-scoped state. This guide covers everything from the basics to production-ready patterns.
What Problem AsyncLocalStorage Actually Solves
Consider this real scenario. You have an Express app. You want a logger module that automatically includes requestId in every log line without the logger needing to receive req as a parameter:
typescript
// You want this to work:
import { logger } from "./logger";
async function getUser(userId: string) {
logger.info("Fetching user"); // Automatically logs requestId but how?
const user = await db.query("SELECT * FROM users WHERE id = ?", [userId]);
logger.info("User fetched", { userId }); // Same here
return user;
}
// Without AsyncLocalStorage, you'd need this ugly alternative:
async function getUser(userId: string, requestId: string) {
logger.info("Fetching user", { requestId }); // Now every function needs requestId
// ...
}
JavaScript is single-threaded, so there's no "current thread" to hang context on. But Node.js's async machinery (the event loop, Promises, async/await) does create a logical "execution context" for each chain of async operations. AsyncLocalStorage hooks into that.
How AsyncLocalStorage Works
Node.js's async_hooks module tracks the lifecycle of async resources Promises, Timers, sockets, and so on. Each async operation has a unique asyncId and knows its triggerAsyncId (which async resource created it). This forms a tree of async contexts.
AsyncLocalStorage uses this tree to propagate a store value: when you call store.run(value, callback), every async operation started inside that callback and every async operation they start inherits the same store value. It propagates automatically through await, Promise.then(), setTimeout, and most other async patterns.
typescript
import { AsyncLocalStorage } from "node:async_hooks";
const store = new AsyncLocalStorage<{ requestId: string }>();
// Outside any run() store is undefined
console.log(store.getStore()); // undefined
store.run({ requestId: "abc-123" }, async () => {
// Inside run() store is set
console.log(store.getStore()?.requestId); // "abc-123"
await new Promise(resolve => setTimeout(resolve, 100));
// Still set after await!
console.log(store.getStore()?.requestId); // "abc-123"
await someDeepFunction(); // Also sees "abc-123" inside
});
async function someDeepFunction() {
// Three levels deep, no parameter passing
console.log(store.getStore()?.requestId); // "abc-123"
}
Real Example 1: Request ID Tracking Across Middleware
The most common use case. Every log line, every error, every database query should include the requestId so you can reconstruct exactly what happened during a specific request in production.
src/request-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
interface RequestMeta {
requestId: string;
method: string;
path: string;
startTime: number;
userId?: string;
}
const asyncStorage = new AsyncLocalStorage<RequestMeta>();
export const RequestContext = {
/**
* Create a new context for a request. Call this at the top of your
* middleware chain before anything else runs.
*/
run<T>(meta: Omit<RequestMeta, "requestId" | "startTime">, fn: () => T): T {
const context: RequestMeta = {
...meta,
requestId: randomUUID(),
startTime: Date.now(),
};
return asyncStorage.run(context, fn);
},
/** Get the current request context. Returns undefined outside a request. */
get(): RequestMeta | undefined {
return asyncStorage.getStore();
},
/** Set a value on the current context (e.g., userId after auth middleware) */
set<K extends keyof RequestMeta>(key: K, value: RequestMeta[K]): void {
const store = asyncStorage.getStore();
if (store) store[key] = value;
},
};
src/middleware/context.ts
import type { Request, Response, NextFunction } from "express";
import { RequestContext } from "../request-context";
export function contextMiddleware(
req: Request,
_res: Response,
next: NextFunction
): void {
// Support forwarded requestId from upstream services (e.g., API gateway)
const forwardedId = req.headers["x-request-id"];
RequestContext.run(
{ method: req.method, path: req.path },
() => next()
);
}
// Auth middleware sets userId after token verification
// Note: verifyToken is your own JWT/session verification function
async function verifyToken(token: string): Promise<{ id: string }> {
// Replace with your actual auth logic (e.g., jwt.verify, DB lookup)
throw new Error("Implement verifyToken with your auth strategy");
}
export async function authMiddleware(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const user = await verifyToken(token);
RequestContext.set("userId", user.id);
}
next();
} catch {
res.status(401).json({ error: "Unauthorized" });
}
}
src/logger.ts
import { RequestContext } from "./request-context";
type LogLevel = "debug" | "info" | "warn" | "error";
function log(level: LogLevel, message: string, meta?: object): void {
const ctx = RequestContext.get();
const entry = {
timestamp: new Date().toISOString(),
level,
message,
// Automatically pulled from AsyncLocalStorage no parameter needed
...(ctx && {
requestId: ctx.requestId,
userId: ctx.userId,
path: ctx.path,
}),
...meta,
};
console[level === "debug" ? "log" : level](JSON.stringify(entry));
}
export const logger = {
debug: (msg: string, meta?: object) => log("debug", msg, meta),
info: (msg: string, meta?: object) => log("info", msg, meta),
warn: (msg: string, meta?: object) => log("warn", msg, meta),
error: (msg: string, meta?: object) => log("error", msg, meta),
};
Real Example 2: Database Query Logging
This is where AsyncLocalStorage really earns its keep. Your database client has no idea it's being called from an HTTP request handler but you can still tie every query to a request.
src/db.ts
import { Pool } from "pg";
import { RequestContext } from "./request-context";
import { logger } from "./logger";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function query<T = unknown>(
sql: string,
params?: unknown[]
): Promise<T[]> {
const start = Date.now();
const ctx = RequestContext.get();
try {
const result = await pool.query(sql, params);
const duration = Date.now() - start;
logger.debug("Query executed", {
sql: sql.replace(/s+/g, " ").trim(),
duration,
rows: result.rowCount,
// requestId comes from logger.debug via AsyncLocalStorage automatically
});
return result.rows as T[];
} catch (err) {
logger.error("Query failed", {
sql: sql.replace(/s+/g, " ").trim(),
duration: Date.now() - start,
error: err instanceof Error ? err.message : String(err),
});
throw err;
}
}
Every query log line now includes requestId and userId automatically, because logger.debug calls RequestContext.get() and the context was set in middleware at the start of the request chain. No parameters changed. No interfaces polluted.
Real Example 3: A Full TypeScript RequestContext Class
For larger applications, you'll want a typed, extensible context that different parts of the system can contribute to:
src/context/request-context.ts
import { AsyncLocalStorage } from "node:async_hooks";
import { randomUUID } from "node:crypto";
// Base context fields every request has
interface BaseContext {
readonly requestId: string;
readonly startTime: bigint; // hrtime for precision
method: string;
path: string;
ip?: string;
}
// Extended by auth middleware
interface AuthContext {
userId?: string;
orgId?: string;
roles?: string[];
}
// Extended by rate limiter
interface RateLimitContext {
rateLimitRemaining?: number;
rateLimitReset?: number;
}
// The full context type union of all contributors
export type AppRequestContext = BaseContext & AuthContext & RateLimitContext;
class RequestContextManager {
private storage = new AsyncLocalStorage<AppRequestContext>();
run<T>(
initial: Pick<AppRequestContext, "method" | "path" | "ip">,
fn: () => T
): T {
const ctx: AppRequestContext = {
requestId: randomUUID(),
startTime: process.hrtime.bigint(),
...initial,
};
return this.storage.run(ctx, fn);
}
get(): AppRequestContext | undefined {
return this.storage.getStore();
}
getOrThrow(): AppRequestContext {
const ctx = this.storage.getStore();
if (!ctx) {
throw new Error(
"RequestContext.getOrThrow() called outside of a request context. " +
"Did you forget to call RequestContext.run() in your middleware?"
);
}
return ctx;
}
patch(updates: Partial<Omit<AppRequestContext, "requestId" | "startTime">>): void {
const ctx = this.storage.getStore();
if (!ctx) return;
Object.assign(ctx, updates);
}
getDurationMs(): number {
const ctx = this.storage.getStore();
if (!ctx) return 0;
return Number(process.hrtime.bigint() - ctx.startTime) / 1_000_000;
}
}
export const RequestContext = new RequestContextManager();
Common Pitfalls
Pitfall 1: Forgetting to call run()
The most common mistake. If you call getStore() before run() has been called anywhere in the async chain, you get undefined. This often manifests as intermittent undefined returns in tests (where there's no real request context).
typescript
// BAD: getStore() called before run()
const store = new AsyncLocalStorage<{ id: string }>();
store.getStore(); // undefined run() was never called
// GOOD: always wrap your code in run()
store.run({ id: "req-1" }, () => {
store.getStore(); // { id: "req-1" }
});
Pitfall 2: Context Loss in setTimeout / setInterval
This one catches people off guard. Scheduled callbacks sometimes lose their context particularly with some older event emitter patterns. The fix is AsyncResource.bind():
typescript
import { AsyncResource } from "node:async_hooks";
import { RequestContext } from "./request-context";
// UNRELIABLE in older Node.js / some edge cases:
setTimeout(() => {
console.log(RequestContext.get()); // might be undefined in older versions
}, 1000);
// ALWAYS RELIABLE bind the callback to the current async context:
const boundCallback = AsyncResource.bind(() => {
console.log(RequestContext.get()); // guaranteed to see the context
});
setTimeout(boundCallback, 1000);
// For event emitters, use AsyncResource.bind on the handler too:
emitter.on("data", AsyncResource.bind((data) => {
logger.info("Data received", { data }); // requestId will be present
}));
NoteSince Node.js 16, context propagation through setTimeout and setImmediate works correctly out of the box. AsyncResource.bind() is still a good defensive practice, and is essential for event emitter callbacks and third-party async patterns.
Pitfall 3: Mutating the Store Object Carelessly
The store is a reference. If you do Object.assign(store, updates) from one place, every other code in the same async context sees the mutation. This is usually what you want but be aware that it means the context is shared and mutable, not copied.
typescript
// Context is shared mutations are visible everywhere in the async tree
const store = new AsyncLocalStorage<{ count: number }>();
store.run({ count: 0 }, async () => {
const ctx = store.getStore()!;
ctx.count++; // Mutation is visible everywhere in this context
await someFunction();
// ctx.count might be 2 here if someFunction also mutated it
});
// If you need immutable contexts, clone before mutating:
store.run({ count: 0 }, () => {
const original = store.getStore()!;
store.run({ ...original, count: original.count + 1 }, () => {
// This is a NEW context original is unaffected
// But child contexts of this one inherit the new value
});
});
Performance: What the Benchmarks Say
The honest answer: AsyncLocalStorage has measurable overhead, but in practice it doesn't matter for web server workloads.
Benchmarks on Node.js 20 (Apple M2, simple HTTP handler):
- Without AsyncLocalStorage: ~85,000 req/s
- With AsyncLocalStorage (run + getStore): ~82,000 req/s
- Overhead: ~3.5% throughput reduction
For a typical API server doing database queries, auth checks, and business logic, 3.5% is noise your database round-trips dominate. The overhead does matter if you're writing high-frequency event processing (millions of events/sec), but for HTTP APIs it's a non-issue.
Memory-wise: each run() call creates a small context object. In a server that handles 1,000 concurrent requests, that's 1,000 small objects alive at any time. Negligible.
When NOT to Use AsyncLocalStorage
Just because you can propagate context implicitly doesn't mean you always should. There are cases where explicit parameter passing is better:
- Pure functions that are unit-tested independently explicit params make testing easier
- Background jobs not tied to a request there's no request context to propagate
- Library code you're publishing don't force your context system on library users
- Simple one-hop cases if you're calling one function that needs a value, just pass it
- Highly concurrent, CPU-bound code the async_hooks overhead accumulates
“AsyncLocalStorage is infrastructure. Use it for cross-cutting concerns logging, tracing, auth context not for passing business logic parameters.”
My own rule of thumb after using it in production
Comparison with cls-hooked (the Old Way)
cls-hooked was the community's answer to this problem before AsyncLocalStorage existed. It used async_hooks directly via monkey-patching and had a reputation for memory leaks and subtle bugs in complex async scenarios.
- `cls-hooked`: third-party dependency, monkey-patches async primitives, memory leak risks, unmaintained since 2019
- `AsyncLocalStorage`: built into Node.js 12.17+, stable in 16+, maintained by the Node.js core team, no monkey-patching
If you're on cls-hooked, migrate. The API is almost identical and you'll sleep better.
TipMinimum Node.js version recommendation: require 16.4+ for AsyncLocalStorage. In Node.js 16.4+, the implementation was rewritten to be significantly faster and more reliable than the 12.17 version. It's the version to target.
AsyncLocalStorage is one of those Node.js features that, once you use it properly, makes you wonder how you ever shipped production code without it. The pattern unlocks clean separation between infrastructure concerns (logging, tracing, auth) and business logic. Build it into your app from day one.