homeresume
 
   
🔍

Request context in Node.js with AsyncLocalStorage

June 28, 2026

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.

ApproachConcurrent requestsPropagates through await
Global variableBreaksYes
Pass ctx argumentSafeManual at every layer
AsyncLocalStorageSafeAutomatic

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, or undefined outside run().
  • als.enterWith(store) - sets context for the current execution resource; useful in some framework hooks, but run() 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() returns undefined outside run() - 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 AsyncLocalStorage instances.

Demo

Runnable code for this post:

Get access via code demos.