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.
2023

Logging practices

February 7, 2023

This post covers some logging practices for the back-end (Node.js) apps.

  • Avoid putting unique identifiers (e.g., user id) within the message. A unique id will produce a lot of different messages with the same context. Use it as a message parameter.

  • Use the appropriate log level for the message. There are multiple log levels

    • info - app behavior, don't log every single step
    • error - app processing failure, something that needs to be fixed
    • debug - additional logs needed for troubleshooting
    • warning - something unexpected happened (e.g., third-party API fails)
    • fatal - app crash, needs to be fixed as soon as possible

Don't use the debug logs on production. Put log level as an environment variable.

  • Stream logs to the standard output in JSON format so logging aggregators (Graylog, e.g.) can collect and adequately parse them

  • Avoid logging any credentials, like passwords, auth tokens, etc.

  • Put correlation ID as a message parameter for tracing related logs.

  • Use a configurable logger like pino

const pino = require('pino');
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
paths: ['token'],
remove: true
}
});
logger.info({ someId: 'id' }, 'Started the app...');
const correlationId = request.headers['correlation-id'] || uuid.v4();
logger.debug(
{ data: 'some data useful for debugging', correlationId },
'Sending the request...'
);
2022

JSON logging bash scripts

March 18, 2022

Logs are usually streamed to the standard output in JSON format so logging aggregators (Graylog, e.g.) can collect and adequately parse them.

The following example shows how bash script output can be formatted with the message, log levels, and timestamp. Error logs are streamed into a temporary file, formatted, and redirected to the standard output.

#!/usr/bin/env bash
declare -A log_levels=( [FATAL]=0 [ERROR]=3 [WARNING]=4 [INFO]=6 [DEBUG]=7)
json_logger() {
log_level=$1
message=$2
level=${log_levels[$log_level]}
timestamp=$(date --iso-8601=seconds)
jq --raw-input --compact-output \
'{
"level": '$level',
"timestamp": "'$timestamp'",
"message": .
}'
}
{
set -e
echo $? 1>&2
echo "Finished"
} 2>/tmp/stderr.log | json_logger "INFO"
cat /tmp/stderr.log | json_logger "ERROR"