Fetching latest headlines…
Stop Passing req Everywhere — Express Middleware for Request Context Propagation
NORTH AMERICA
🇺🇸 United StatesMay 11, 2026

Stop Passing req Everywhere — Express Middleware for Request Context Propagation

1 views0 likes0 comments
Originally published byDev.to

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/await
  • Promise.then()
  • setTimeout / setInterval
  • EventEmitter callbacks
  • 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

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.

Comments (0)

Sign in to join the discussion

Be the first to comment!