Request context in Node.js with AsyncLocalStorage
June 28, 2026In Express and NestJS apps you often need a requestId, userId, or trace fields deep in services and loggers without threading a context object through every function. A module-level global breaks under concurrent requests; passing ctx through every layer is noisy.
AsyncLocalStorage (built into node:async_hooks) gives each async execution chain its own store. Concurrent requests stay isolated, and await, timers, and Promise chains inside a request inherit the same context automatically.
This post covers the API, an Express middleware pattern, a NestJS interceptor with parameter decorators, and common pitfalls.
What AsyncLocalStorage is
AsyncLocalStorage has been stable since Node.js 16.4. No npm package is required.
| Approach | Concurrent requests | Propagates through await |
|---|---|---|
| Global variable | Breaks | Yes |
Pass ctx argument | Safe | Manual at every layer |
| AsyncLocalStorage | Safe | Automatic |
Prerequisites
- Node.js version 26
npm i express
Core API
import { AsyncLocalStorage } from 'node:async_hooks';const als = new AsyncLocalStorage();// Start a context for this async chainals.run({ requestId: 'abc' }, () => {doWork();});// Read the current store anywhere in the chainconst store = als.getStore();
als.run(store, callback)- preferred entry point; call once per request in middleware.als.getStore()- returns the store for the current async chain, orundefinedoutsiderun().als.enterWith(store)- sets context for the current execution resource; useful in some framework hooks, butrun()is the default pattern for Express middleware.
Express middleware pattern
Create a store per request in middleware, then read it from loggers and services without passing arguments.
// context.jsimport { AsyncLocalStorage } from 'node:async_hooks';export const requestContext = new AsyncLocalStorage();export function getRequestId() {return requestContext.getStore()?.requestId;}
// logger.jsimport { getRequestId } from './context.js';export function log(level, message, extra = {}) {console.log(JSON.stringify({level,requestId: getRequestId(),message,...extra,}));}
// middleware.jsimport { randomUUID } from 'node:crypto';import { requestContext } from './context.js';export function requestContextMiddleware(req, res, next) {const store = {requestId: req.headers['x-request-id'] ?? randomUUID(),};res.setHeader('x-request-id', store.requestId);requestContext.run(store, () => next());}
Wire middleware before routes. A handler can await a simulated database call and log without receiving requestId:
// routes.jsimport { log } from './logger.js';function delay(ms) {return new Promise((resolve) => setTimeout(resolve, ms));}async function findUserById(id) {await delay(100);log('info', 'Loaded user from database', { userId: id });return { id, name: `User ${id}` };}export async function getUser(req, res) {const user = await findUserById(req.params.id);res.json(user);}
// app.jsimport express from 'express';import { requestContextMiddleware } from './middleware.js';import { getUser } from './routes.js';import { getRequestId } from './context.js';const app = express();app.use(requestContextMiddleware);app.get('/users/:id', getUser);app.listen(3000, () => {console.log('Listening on http://localhost:3000');});
Send concurrent requests with different x-request-id headers. Logs interleave on stdout, but each line carries the correct requestId for its request.
NestJS interceptor and decorator
The flow is the same as Express - an interceptor replaces middleware. For production apps, nestjs-cls wraps AsyncLocalStorage with typed stores and plugins; the snippets below use the built-in API directly.
Prerequisites add-on: npm i @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs
Shared store:
// request-context.storage.tsimport { AsyncLocalStorage } from 'node:async_hooks';export type RequestContextStore = { requestId: string };export const requestContext = new AsyncLocalStorage<RequestContextStore>();
Wrap each request in als.run() with a global interceptor. Subscribe inside run() so context propagates through async route handlers:
// request-context.interceptor.tsimport {CallHandler,ExecutionContext,Injectable,NestInterceptor,} from '@nestjs/common';import { randomUUID } from 'node:crypto';import { Observable } from 'rxjs';import { requestContext, RequestContextStore } from './request-context.storage';@Injectable()export class RequestContextInterceptor implements NestInterceptor {intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {const req = context.switchToHttp().getRequest();const store: RequestContextStore = {requestId: (req.headers['x-request-id'] as string) ?? randomUUID(),};const res = context.switchToHttp().getResponse();res.setHeader('x-request-id', store.requestId);return new Observable((subscriber) => {requestContext.run(store, () => {next.handle().subscribe(subscriber);});});}}
Parameter decorators read from the store in controllers:
// request-context.decorator.tsimport { createParamDecorator, ExecutionContext } from '@nestjs/common';import { requestContext, RequestContextStore } from './request-context.storage';export const RequestContext = createParamDecorator((_data: unknown, _ctx: ExecutionContext): RequestContextStore | undefined =>requestContext.getStore(),);export const RequestId = createParamDecorator((): string | undefined => requestContext.getStore()?.requestId,);
Register the interceptor globally and use @RequestId() in a controller. Services can call requestContext.getStore() directly (for example from an injectable logger):
// app.module.tsimport { Module } from '@nestjs/common';import { APP_INTERCEPTOR } from '@nestjs/core';import { RequestContextInterceptor } from './request-context/request-context.interceptor';import { UsersModule } from './users/users.module';@Module({imports: [UsersModule],providers: [{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor }],})export class AppModule {}
// users.controller.ts@Get(':id')async getUser(@Param('id') id: string, @RequestId() requestId: string) {const user = await this.usersService.findById(id);return { requestId, user };}
@Transactional() from typeorm-transactional uses the same ALS propagation idea for database transactions. See TypeORM examples with NestJS for a real-world decorator built on top of request-scoped context.
Why it works
Node ties the store to the async resource created when als.run() executes. Any async work started inside that callback - including await, setTimeout, and nested Promise chains - runs in the same context. Each concurrent request calls als.run() with its own store, so getStore() always returns the value for the current chain.
Common pitfalls
- Call
als.run()at the request boundary (middleware), not inside a shared singleton constructor. getStore()returnsundefinedoutsiderun()- guard with optional chaining or a default.- Callbacks scheduled before
run()or from another request will not see your store. - Worker threads and cluster workers each have separate
AsyncLocalStorageinstances.
Related use cases
- Structured logging - attach
requestIdto every log line (this post). - Distributed tracing - OpenTelemetry uses
AsyncLocalStorageinternally; see Tracing Node.js Microservices with OpenTelemetry. - Database transactions - typeorm-transactional propagates transactions across repositories via ALS; see TypeORM examples with NestJS.
Demo
Runnable code for this post:
- Express - async-local-storage-nodejs-demo
- NestJS - async-local-storage-nestjs-demo
Get access via code demos.