There's a pattern I've seen in almost every Express codebase I've worked in. It starts small.
You need to log a correlation ID inside a service function. So you add req as a parameter. Then the function that calls it needs req. Then the one above that. Within a few weeks, half your codebase takes req as a parameter even though it only ever reads one field from it.
// This is where it starts
async function processOrder(orderId: string, req: Request) {
logger.info('Processing order', { correlationId: req.headers['x-correlation-id'] });
await notifyWarehouse(orderId, req); // now warehouse needs req too
await sendEmail(orderId, req); // and email
await auditLog('order.processed', req); // and audit
}
Every function signature is polluted. Tests need a mock req. Services are coupled to Express internals. It's a mess.
The fix is AsyncLocalStorage — a Node.js built-in (stable since v16) that lets you store data scoped to an async call chain and read it from anywhere inside that chain, without passing it through function parameters.
I wrapped it into express-correlation-context — a single middleware that handles everything.
Installation
npm install express-correlation-context
Zero runtime dependencies.
Quick Start
import express from 'express';
import { correlationContext, getContext, getCorrelationId } from 'express-correlation-context';
const app = express();
// Register once — before your routes
app.use(correlationContext());
app.get('/orders', async (_req, res) => {
const orders = await orderService.list(); // no req needed
res.json(orders);
});
Inside orderService.list(), deep in your service layer:
import { getCorrelationId } from 'express-correlation-context';
async function list() {
logger.info('Fetching orders', { correlationId: getCorrelationId() }); // just works
return db.orders.findAll();
}
What's in the context
Every request automatically gets:
const ctx = getContext();
ctx.correlationId // UUID auto-generated, or read from x-correlation-id header
ctx.startTime // Unix ms when request started
ctx.duration() // ms elapsed since startTime — call it whenever you need it
ctx.ip // client IP, respects x-forwarded-for for proxied requests
ctx.method // 'GET', 'POST', etc.
ctx.path // '/api/orders/123'
ctx.userAgent // 'Mozilla/5.0...'
And you can add anything:
import { setContext } from 'express-correlation-context';
// In an auth middleware, after verifying the JWT:
setContext('userId', decoded.sub);
setContext('tenantId', decoded.tenant);
// Then anywhere downstream:
getContext()?.userId // 'u_abc123'
getContext()?.tenantId // 'tenant_xyz'
How AsyncLocalStorage makes this work
AsyncLocalStorage creates a storage slot that automatically propagates through async continuations in Node.js. That means it works through:
async/awaitPromise.then()-
setTimeout/setInterval -
EventEmittercallbacks - Streams
The middleware creates a new store for each request and runs next() inside it:
// Simplified internals
export function correlationContext(options = {}) {
return (req, res, next) => {
const ctx = {
correlationId: req.headers['x-correlation-id'] ?? randomUUID(),
startTime: Date.now(),
duration: () => Date.now() - startTime,
ip: getClientIp(req),
method: req.method,
path: req.path,
userAgent: req.headers['user-agent'] ?? '',
};
storage.run(ctx, next); // next() and everything it calls runs inside this store
};
}
Each request gets its own isolated store. Concurrent requests can't bleed into each other — the runtime guarantees it.
The onContext hook
This is the feature I use most in real apps. It lets you enrich the context right after it's created, using data from earlier middlewares:
app.use(jwtMiddleware); // sets req.user
app.use(
correlationContext({
onContext: (ctx, req) => {
const user = (req as AuthedRequest).user;
if (user) {
ctx.userId = user.id;
ctx.tenantId = user.tenantId;
ctx.role = user.role;
}
},
}),
);
Now userId, tenantId, and role are available everywhere without ever touching req again.
Structured logging — the biggest win
The most immediate benefit is logging. Instead of passing a logger instance around, create a wrapper that automatically picks up the correlation ID:
import pino from 'pino';
import { getContext } from 'express-correlation-context';
const base = pino();
export const logger = {
info: (msg: string, data?: object) =>
base.info({ correlationId: getContext()?.correlationId, ...data }, msg),
warn: (msg: string, data?: object) =>
base.warn({ correlationId: getContext()?.correlationId, ...data }, msg),
error: (msg: string, data?: object) =>
base.error({ correlationId: getContext()?.correlationId, ...data }, msg),
};
Every log line now includes the correlation ID automatically. When something goes wrong in production, you filter by correlationId and see the entire request trace — all services, all layers — in one query.
Response time header
app.use(correlationContext());
app.use((_req, res, next) => {
res.on('finish', () => {
const ms = getContext()?.duration();
if (ms !== undefined) res.setHeader('x-response-time', `${ms}ms`);
});
next();
});
All options
app.use(correlationContext({
header: 'x-correlation-id', // header to read/write. Default: 'x-correlation-id'
generateId: () => nanoid(), // custom ID generator. Default: crypto.randomUUID()
echoHeader: true, // echo ID in response. Default: true
onContext: (ctx, req) => { ... }, // enrich context after creation
}));
Works with NestJS
// main.ts
import { correlationContext } from 'express-correlation-context';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(correlationContext());
await app.listen(3000);
}
Then call getContext() from any NestJS service, interceptor, or guard — no injection token needed.
TypeScript — extend the context type
import { getContext, CorrelationContext } from 'express-correlation-context';
interface AppContext extends CorrelationContext {
userId: string;
tenantId: string;
role: string;
}
// Cast once in a helper, use everywhere
export function ctx(): AppContext | null {
return getContext() as AppContext | null;
}
Testing
Because getContext() returns null outside a request, your unit tests don't need to mock anything — functions that call getContext()?.correlationId just get null?.correlationId = undefined, which is fine for most logging calls.
For tests where you need a real context:
import { runWithContext } from 'express-correlation-context';
it('logs with correlationId', () => {
runWithContext(
{ correlationId: 'test-id', /* ...other fields */ },
() => {
const result = myService.doSomething();
expect(result.traceId).toBe('test-id');
}
);
});
Before vs after
Before:
// Every function takes req just for the correlation ID
async function sendEmail(to: string, template: string, req: Request) {
logger.info('Sending email', {
correlationId: req.headers['x-correlation-id'],
to,
template,
});
// ...
}
After:
// Clean signature — context is ambient
async function sendEmail(to: string, template: string) {
logger.info('Sending email', { to, template }); // logger adds correlationId automatically
// ...
}
Links
- npm: npmjs.com/package/express-correlation-context
- GitHub: github.com/SaifuddinTipu/express-correlation-context
26 tests, full TypeScript, zero runtime dependencies.
If you've been passing req through 6 layers of service functions, this is the fix. Drop a ⭐ if it's useful.
United States
NORTH AMERICA
Related News
What Does "Building in Public" Actually Mean in 2026?
19h ago
The Agentic Headless Backend: What Vibe Coders Still Need After the UI Is Done
19h ago
Why I’m Still Learning to Code Even With AI
21h ago
I gave Claude a persistent memory for $0/month using Cloudflare
1d ago
NYT: 'Meta's Embrace of AI Is Making Its Employees Miserable'
1d ago