In 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 chain
als.run({ requestId: 'abc' }, () => {
doWork();
});
// Read the current store anywhere in the chain
const 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.js
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage();
export function getRequestId() {
return requestContext.getStore()?.requestId;
}
// logger.js
import { getRequestId } from './context.js';
export function log(level, message, extra = {}) {
console.log(
JSON.stringify({
level,
requestId: getRequestId(),
message,
...extra,
})
);
}
// middleware.js
import { 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.js
import { 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.js
import 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.ts
import { 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.ts
import {
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.ts
import { 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.ts
import { 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.
Top comments (0)