Propagating Request Context Without Middleware Using Node.js 24 AsyncLocalStorage — How to Flow Trace IDs and User Information Through the Entire Call Stack
When operating a production Node.js server, there are moments you inevitably run into. You dig through logs but can't trace a specific request's flow because there's no trace ID, or a function deep inside the service layer needs user info and you end up passing userId all the way down the call stack. I used to think attaching things to the req object in Express middleware was the right approach — until service functions started being called outside middleware, and that pattern hit its limits.
AsyncLocalStorage solves this problem at the runtime level. Without adding function arguments or depending on a framework, it automatically flows per-request context through the entire async execution chain. It's a built-in API in node:async_hooks, so no external packages are needed. And starting with Node.js 24, the internal implementation switched to AsyncContextFrame, significantly reducing the performance overhead.
This article covers how AsyncLocalStorage works internally, how to use it in real scenarios like trace IDs, user info, and multi-tenant routing, and what mistakes are commonly made in practice.
Why AsyncLocalStorage Now
The Pain of Prop Drilling
Once your service layer gets even slightly deep, code like this appears.
// Router
router.get('/order/:id', (req, res) => {
orderService.getOrder(req.params.id, req.user, req.headers['x-trace-id']);
});
// Service
async function getOrder(orderId: string, user: User, traceId: string) {
return paymentService.getPayment(orderId, user, traceId);
}
// Payment service
async function getPayment(orderId: string, user: User, traceId: string) {
logger.info({ traceId, userId: user.id }, 'Fetching payment');
// ...
}traceId and user follow every function as parameters. Changing a function signature requires cascading edits, and when tenantId gets added later, you repeat the whole thing from scratch.
Node.js 24 and AsyncContextFrame
AsyncLocalStorage has been Stable since Node.js 16.4.0, but internally it operated on async_hooks-based hooks, incurring overhead from executing hooks at every async boundary. As of 2026, Node.js 24 (released April 2025) switched the default implementation to AsyncContextFrame. This isn't V8 natively tracking async context (that's partly why the TC39 proposal-async-context is pursuing standardization) — it's a reimplementation of how the Node.js runtime propagates execution context. 15–40% throughput improvements have been reported for workloads that rely on context tracking.
A rollback flag is provided to revert to the legacy behavior, but check the Node.js 24.0.0 release notes for the exact switch name before using it. For new projects, there's no reason to roll back.
Convergence is also happening at the ecosystem level. Cloudflare Workers, Deno, and Bun have all started supporting the AsyncLocalStorage API, and TC39 is working on a proposal to add AsyncContext as an ECMAScript standard for use across all JS runtimes, including browsers. Node.js's AsyncLocalStorage is serving as the transitional implementation.
How AsyncLocalStorage Flows Context
The core is three lines.
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
als.run({ traceId: '123', userId: null }, () => {
// All Promises, setTimeouts, and I/O callbacks created inside this callback
// automatically inherit the same store
someAsyncFunction();
});
async function someAsyncFunction() {
await fetch('https://api.example.com');
// Even after fetch, at any depth
const store = als.getStore(); // { traceId: '123', userId: null }
}The moment als.run(store, callback) is called, the store is bound to the current execution context, and all async executions derived from that callback automatically inherit the same store. als.getStore() returns the same object no matter where it's called.
The diagram below shows this flow.
The difference from a middleware chain shows here. Express/Fastify middleware can only pass req and res within the framework layer. AsyncLocalStorage, on the other hand, maintains context in pure functions outside the framework, independent modules, and even inside setTimeout.
Code by Real Scenario
Scenario 1: Auto-injecting traceId into Structured Logging
Instead of manually inserting traceId in every log call, you can use pino's mixin option to automatically merge store values into every log.
// context.js
import { AsyncLocalStorage } from 'node:async_hooks';
export const als = new AsyncLocalStorage();
// logger.js
import pino from 'pino';
import { als } from './context.js';
export const logger = pino({
mixin() {
const store = als.getStore();
return store ? { traceId: store.traceId, userId: store.userId } : {};
},
});
// app.js — Express example; als.run() is called exactly once here
import crypto from 'node:crypto';
import { als } from './context.js';
app.use((req, res, next) => {
als.run(
{
traceId: req.headers['x-trace-id'] ?? crypto.randomUUID(),
userId: null,
},
next
);
});
// emailService.js — traceId is included automatically just by importing
import { logger } from './logger.js';
export function sendEmail(to, subject) {
logger.info({ to, subject }, 'Sending email');
// Log output: { traceId: 'abc-123', userId: null, to: '...', subject: '...' }
}No matter where in the call stack sendEmail is called, it never needs to receive traceId as an argument.
Scenario 2: Loading User Info After JWT Verification
There are two ways to add values to the store here. This article recommends having each middleware re-open als.run() with a new store. The reason comes a bit later — let's look at the recommended code first.
// authMiddleware.js
import jwt from 'jsonwebtoken';
import { als } from './context.js';
export function authMiddleware(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).end();
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (err) {
return res.status(401).json({ error: 'invalid token' });
}
const prev = als.getStore() ?? {};
als.run({ ...prev, user: decoded }, next);
}
// auditService.js — accessible without a user parameter
import { als } from './context.js';
import { db } from './db.js';
export function auditLog(action) {
const { traceId, user } = als.getStore() ?? {};
db.insert('audit_logs', { action, userId: user?.id, traceId });
}jwt.verify throws a synchronous exception on verification failure, so skipping this try-catch will crash the middleware. When adding values to the store, re-open als.run() with a new object that spreads the existing store. Passing next as the callback to run() in an Express middleware chain means subsequent middleware and route handlers execute inside the new context, making it flow naturally.
So why avoid mutating the existing object directly like store.user = decoded? It looks convenient on the surface, but the moment the same store reference is reused somewhere or the store object leaks into another scope, data can bleed between requests. Re-opening run() with a new object eliminates this risk entirely, and it also makes it clear in code which middleware added which fields as you trace up the stack.
Scenario 3: Multi-tenant DB Connection Routing
// db.js
import { als } from './context.js';
const connectionPool = new Map(); // tenantId -> db client
export function getDbClient() {
const store = als.getStore();
const tenantId = store?.tenantId;
if (!tenantId) throw new Error('No tenant context');
const client = connectionPool.get(tenantId);
if (!client) throw new Error(`Unknown tenant: ${tenantId}`);
return client;
}Set tenantId in the store once at the request entry point, and from that point on, getDbClient() retrieves the correct connection no matter where the DB query functions are called — no need to pass tenantId as a function argument. Adding defensive code that throws immediately when there's no context or an unknown tenant saves time you'd otherwise spend reverse-engineering a weird undefined.query is not a function stack trace later.
Scenario 4: Configuring the Entry Point with a Single Hook in Fastify
import Fastify from 'fastify';
import { als } from './context.js';
import crypto from 'node:crypto';
const fastify = Fastify();
fastify.addHook('onRequest', (req, reply, done) => {
const traceId = req.headers['x-trace-id'] ?? crypto.randomUUID();
als.run(
{ traceId, userId: null },
done // done executes inside the als.run context
);
});Fastify's req.id defaults to an integer that increments within a single process, restarting from 1 when the process restarts. It does not guarantee global uniqueness in a distributed environment. So in environments with multiple instances or microservices, it's safer to prioritize the incoming x-trace-id as shown above, and generate with crypto.randomUUID() only when it's absent.
Scenario 5: Natural Integration with OpenTelemetry
Honestly, this requires almost no extra work. The OpenTelemetry Node.js SDK has already switched to using AsyncLocalStorageContextManager internally to manage the current span. So without any additional configuration at the app layer, you can automatically attach the active span's traceId/spanId to logs.
// OTel initialization (separate file)
import { NodeSDK } from '@opentelemetry/sdk-node';
import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks';
const sdk = new NodeSDK({
contextManager: new AsyncLocalStorageContextManager(),
// ... other config
});
sdk.start();After this initialization, the span context managed by OTel and the als store managed by the app operate on the same mechanism, so there's no conflict.
The Full Picture of Context Flow
Here's a sequence view of how context flows from an incoming request to an outgoing response.
The middleware calls run() exactly once at the entry point — from there, all async calls automatically operate with the same store.
Pros and Cons, and Common Mistakes in Practice
First, a summary table of pros and cons.
| Item | Pros | Cons / Caveats |
|---|---|---|
| Interface simplicity | No need to pass context as function arguments | Context flows implicitly, making it hard to understand flow from code alone |
| Framework coupling | Same pattern works with Express, Fastify, NestJS, and plain http |
als.run() must be called at the entry point |
| Performance (Node.js 24+) | 15–40% throughput improvement reported with AsyncContextFrame | Still not completely free. Per-workload measurement recommended |
| External dependencies | Built into node:async_hooks, no installation needed |
Becomes a singleton referenced anywhere in the app, requiring management discipline |
| OTel compatibility | SDK internals use the same mechanism | Does not auto-propagate across worker_threads boundaries |
Mistakes to watch out for:
1. No propagation across worker_threads boundaries
Worker threads have a separate V8 context. The parent thread's store is not automatically propagated, so you must serialize and pass it via MessagePort.
2. Calling getStore() without als.run()
Calling getStore() outside of als.run() returns undefined. Always include defensive code.
// Bad
const { traceId } = als.getStore(); // TypeError if outside run()
// Better
const { traceId } = als.getStore() ?? { traceId: 'unknown' };3. Data contamination from shared store objects
This is the most dangerous one. If you reuse the same object reference across als.run() calls instead of creating a new object each time, data can bleed between requests.
// Never do this
const sharedStore = { traceId: null }; // Shared across all requests
app.use((req, res, next) => {
sharedStore.traceId = req.headers['x-trace-id']; // Other requests will overwrite this
als.run(sharedStore, next);
});
// Correct — always a new object
app.use((req, res, next) => {
als.run({ traceId: req.headers['x-trace-id'] ?? crypto.randomUUID() }, next);
});4. Creating multiple instances and complicating management
It's recommended to keep one AsyncLocalStorage instance per process. Creating new AsyncLocalStorage() in each module fragments the store and makes tracking difficult. Exporting a singleton from a single context.js is the clean pattern.
5. Debugging difficulty from context flowing 'invisibly'
The advantage becomes the disadvantage here. It's not immediately obvious from code alone where the store was set. Documenting where als.run() is called as a team convention, or explicitly typing the store with TypeScript, helps a lot.
// context.ts
interface RequestStore {
traceId: string;
userId: string | null;
tenantId?: string;
}
export const als = new AsyncLocalStorage<RequestStore>();With types, the return value of getStore() is also type-inferred, giving you proper IDE support.
Here's a diagram of the decision flow.
Closing — Why Now Is the Time to Adopt
AsyncLocalStorage itself is not a new API. But for teams that have been putting off adoption because of 'good idea, but the overhead is a concern,' the AsyncContextFrame switch in Node.js 24 is reason enough to revisit that decision. The reported 15–40% throughput improvement for context-tracking workloads, the TC39 AsyncContext standardization that's establishing cross-runtime portability, and the fact that the OpenTelemetry SDK is already running on the same mechanism — all point in the same direction.
The highest-value first step is auto-injecting traceId into structured logging. A few lines connecting the store to pino's mixin means every subsequent log comes out with a trace ID attached, and you can incrementally layer on multi-tenant routing or audit logging from there. als.run() once at the request entry point, re-open with a new object when adding values in middleware. Get those two rules agreed on within the team, and the rest takes care of itself.
Note: Teams using NestJS can further reduce adoption cost with the
nestjs-clspackage, which abstracts this pattern with integration across Guard, Interceptor, and Service layers.
References
- Node.js Official Docs — Asynchronous context tracking
- Node.js 24.0.0 Release Notes — AsyncContextFrame as default
- What's New in Node.js 24 — AppSignal Blog
- Contextual Logging Done Right in Node.js with AsyncLocalStorage — Dash0
- The Hidden Cost of Async Context in Node.js — Platformatic Blog
- Node.js AsyncLocalStorage: Pass Context Without Prop Drilling — Trevor Lasn
- @opentelemetry/context-async-hooks — npm
- AsyncLocalStorage — Cloudflare Workers Docs
- TC39 AsyncContext proposal
- WinterCG AsyncLocalStorage Portable Subset Spec