backend

Correlation IDs in Node.js with AsyncLocalStorage (2026)

July 1, 2026

Correlation IDs in Node.js with AsyncLocalStorage (2026)

You add a correlation ID to every log in Node.js by storing a per-request ID in AsyncLocalStorage and reading it from a pino mixin. One Express middleware generates or reuses the ID, so every log line, error handler, and outbound call reads it from context, not a threaded parameter.

TL;DR

When a single request fans out across middleware, services, and await points, you need one ID stitching its log lines together. This tutorial wires that up with the built-in node:async_hooks AsyncLocalStorage1 — no third-party correlation library — on Express 5.2.1 with pino 10.3.1 and TypeScript 6.0.3.2 You'll build a typed request-context store, an Express middleware that reuses an inbound x-correlation-id or mints a fresh UUID, a pino logger that stamps the ID onto every line, and outbound propagation to downstream services. It's about six small files and 20 minutes. Every code block and every log line below was run on Node 22 and verified before publishing.

What you'll learn

  • Why AsyncLocalStorage beats passing a requestId argument through every function
  • How to create a typed request-context store with AsyncLocalStorage<RequestContext>
  • How to write an Express middleware that reuses or generates a correlation ID
  • How to make pino attach the correlation ID to every log line with a mixin
  • How to read the ID in route handlers and error handlers without threading it
  • How to propagate the correlation ID to downstream services on outbound fetch calls
  • Why getStore() sometimes returns undefined, and how AsyncResource.bind fixes it
  • How to verify the whole flow with curl and real log output

Prerequisites

  • Node.js 20 or newer (Node 22 LTS recommended). AsyncLocalStorage has been Stable since Node 16.41; pino 10 targets a current LTS. Everything below was verified on Node 22.22.3.
  • pino 10.3.1, Express 5.2.1, TypeScript 6.0.3, and tsx 4.22.4, pinned so your build is reproducible.2
  • Comfort with Express middleware and async/await.

No third-party correlation-ID package is required. Libraries like express-correlation-id and correlation-id exist and work, but the mechanism they wrap — AsyncLocalStorage — ships with Node, so we'll use it directly and keep the context layer dependency-free.

Step 1 — Scaffold a Node.js + Express project

Create a project, switch it to ES modules, and install the pinned packages:

mkdir correlation-demo && cd correlation-demo
npm init -y
npm pkg set type=module
npm install pino@10.3.1 express@5.2.1
npm install -D typescript@6.0.3 tsx@4.22.4 @types/node@26.0.1 @types/express@5.0.6

Add a tsconfig.json. We run TypeScript directly with tsx (no build step) and use tsc --noEmit only to type-check, so .ts import extensions are allowed:

{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "*.ts"]
}

Step 2 — Create a typed request-context store

AsyncLocalStorage gives each request its own "box" of data that follows the asynchronous flow — across await, promises, and callbacks — so you don't drag a requestId argument through every function.1 Define the store once and export small typed helpers:

// src/context.ts
import { AsyncLocalStorage } from 'node:async_hooks';

export interface RequestContext {
  correlationId: string;
  startedAt: number;
}

const storage = new AsyncLocalStorage<RequestContext>();

export function runWithContext<T>(context: RequestContext, callback: () => T): T {
  return storage.run(context, callback);
}

export function getContext(): RequestContext | undefined {
  return storage.getStore();
}

export function getCorrelationId(): string | undefined {
  return storage.getStore()?.correlationId;
}

Two things matter here. The store is typed as AsyncLocalStorage<RequestContext>, so getStore() returns RequestContext | undefined — the undefined is real, and TypeScript forces you to handle the "no active request" case. And we wrap storage.run, not storage.enterWith: the Node.js docs recommend run() because enterWith() does not automatically exit — its context persists through later synchronous and asynchronous work instead of being scoped to a callback.3

Step 3 — Write the correlation-ID middleware

The middleware is where a request enters the store. It reuses an inbound x-correlation-id header if the caller sent one (so an ID assigned by your gateway or an upstream service survives), otherwise it mints a fresh v4 UUID with the built-in crypto.randomUUID().4 It echoes the ID back on the response and then runs the rest of the request inside the store:

// src/correlation.ts
import type { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'node:crypto';
import { runWithContext } from './context.ts';

const HEADER = 'x-correlation-id';

export function correlationId(req: Request, res: Response, next: NextFunction): void {
  const incoming = req.header(HEADER);
  const id = incoming && incoming.trim() !== '' ? incoming : randomUUID();
  res.setHeader(HEADER, id);
  runWithContext({ correlationId: id, startedAt: Date.now() }, next);
}

Passing next as the run callback is the whole trick: because Express invokes the rest of the middleware chain and your route handler from within that call, they all execute inside the store — including everything that happens after an await.

Step 4 — Make pino attach the ID to every log line

You get the correlation ID onto every log line with pino's mixin option: a function pino calls on every log, merging its return value into the record.5 Read the store inside it, and if a request is active, add the ID:

// src/logger.ts
import pino from 'pino';
import { getContext } from './context.ts';

export const logger = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  mixin() {
    const context = getContext();
    return context ? { correlationId: context.correlationId } : {};
  },
});

Because the store is populated in Step 3's middleware, any logger.info(...) inside a request picks up the ID with zero extra arguments. Log outside a request — at startup, say — and the mixin returns {}, so there's no stray correlationId field. You never pass the logger a request ID by hand.

Step 5 — Wire the server and read the ID anywhere

Register the middleware before your routes, then read the ID anywhere downstream with getCorrelationId() — no handler needs it as a parameter:

// src/server.ts
import express from 'express';
import type { Request, Response, NextFunction } from 'express';
import { correlationId } from './correlation.ts';
import { logger } from './logger.ts';
import { getCorrelationId } from './context.ts';

const app = express();
app.use(correlationId);

app.get('/work', async (_req: Request, res: Response) => {
  logger.info('handling /work');
  await new Promise((resolve) => setTimeout(resolve, 20));
  logger.info('done with async work');
  res.json({ ok: true, correlationId: getCorrelationId() });
});

app.get('/boom', async (_req: Request, _res: Response) => {
  await new Promise((resolve) => setTimeout(resolve, 5));
  throw new Error('downstream exploded');
});

app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
  logger.error({ err: err.message }, 'request failed');
  res.status(500).json({ error: 'internal', correlationId: getCorrelationId() });
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => logger.info({ port }, 'listening'));

Run it with npx tsx src/server.ts. The /work route logs twice — once before a 20 ms delay and once after — and both lines carry the same ID, proving the store survives the await.

Step 6 — Catch errors with the context intact

Error handlers are exactly where a correlation ID earns its keep, and this is a place Express 5 helps. In Express 5, a rejected promise thrown from an async route handler is forwarded to your error-handling middleware automatically — no try/catch and no express-async-errors shim.6 Because the rejection propagates on the async continuation that began inside run(), the error handler is still inside the store, so getCorrelationId() and the pino mixin both resolve. That's why the /boom handler above throws freely and the final app.use((err, …)) block can log and return the same ID.

Step 7 — Propagate the ID to downstream services

A correlation ID is only useful across a system if it travels. Forward it on outbound calls by reading the store and setting the header on your fetch request:

// src/downstream.ts
import { getCorrelationId } from './context.ts';

export async function callDownstream(url: string): Promise<Response> {
  const id = getCorrelationId();
  const headers: Record<string, string> = {};
  if (id) headers['x-correlation-id'] = id;
  return fetch(url, { headers });
}

Call callDownstream(url) from inside any request handler and the receiving service gets the same x-correlation-id. If that service runs the middleware from Step 3, it reuses the ID instead of minting a new one, and one ID now spans both services' logs. I verified this with a /proxy route calling a local /echo service: with an inbound trace-abc, the response was {"here":"trace-abc","downstreamReceived":"trace-abc"}.

Why getStore() returns undefined: the EventEmitter pitfall

A common AsyncLocalStorage bug is getStore() returning undefined where you expected context. The cause is almost always an escape from the async chain — most often an EventEmitter whose listener was registered during a request but fires later, outside it. Listeners run with the context active at emit time, not registration time, so a decoupled emit sees no store.

The fix is AsyncResource.bind, which captures the current context and re-enters it when the wrapped function runs.7 Here is the pitfall and the fix side by side:

// src/pitfalls.ts
import { AsyncLocalStorage, AsyncResource } from 'node:async_hooks';
import { EventEmitter } from 'node:events';

const als = new AsyncLocalStorage<{ id: string }>();
const bus = new EventEmitter();
let fireUnbound: () => void = () => {};
let fireBound: () => void = () => {};

als.run({ id: 'req-2' }, () => {
  bus.on('unbound', () => {
    console.log('UNBOUND listener store =', als.getStore()?.id);
  });
  bus.on('bound', AsyncResource.bind(() => {
    console.log('BOUND listener store   =', als.getStore()?.id);
  }));
  fireUnbound = () => bus.emit('unbound');
  fireBound = () => bus.emit('bound');
});

// Emit later, outside any als.run() context (e.g. from a timer):
setTimeout(() => {
  fireUnbound();
  fireBound();
}, 30);

Running this prints the unbound listener losing context and the bound one keeping it:

UNBOUND listener store = undefined
BOUND listener store   = req-2

Plain await and setTimeout do not lose context — they're part of the async chain AsyncLocalStorage tracks, which is why Step 5 worked without any binding. Only when you break out of that chain (event emitters, a shared connection's callbacks, some third-party callback APIs) do you need AsyncResource.bind. Node 24 goes further and swaps the internal implementation to AsyncContextFrame by default for better performance and robustness, but the code above runs the same on Node 20, 22, and 24.8

Verification

Start the server, then exercise it with curl. First, let the server generate an ID:

npx tsx src/server.ts   # in one terminal
curl -si http://localhost:3000/work | grep -i x-correlation-id

You'll see the response carry a generated ID, for example:

x-correlation-id: 9b1e5d02-6b1a-4a1f-8b3a-2c0d5f7e9a11

Now send your own ID and watch it get reused rather than replaced:

curl -s -H 'x-correlation-id: abc-123' http://localhost:3000/work
# {"ok":true,"correlationId":"abc-123"}

In the server terminal, both log lines for that request share the ID. pino writes newline-delimited JSON; level 30 is info, time is epoch milliseconds, and pid/hostname/time will differ on your machine:

{"level":30,"time":1782897300000,"pid":4821,"hostname":"api-01","correlationId":"abc-123","msg":"handling /work"}
{"level":30,"time":1782897300021,"pid":4821,"hostname":"api-01","correlationId":"abc-123","msg":"done with async work"}

Finally, confirm the error path keeps the ID:

curl -s -H 'x-correlation-id: boom-777' http://localhost:3000/boom
# {"error":"internal","correlationId":"boom-777"}

The matching server log is an error line (level 50) that also carries boom-777 — the ID survived the throw into the error handler.

Troubleshooting

getStore() returns undefined inside a route. The store is only set for code that runs downstream of the correlationId middleware. Make sure app.use(correlationId) comes before the routes that read it, and that nothing calls next() outside the run callback.

The ID is missing only inside an event/callback. You've left the async chain — see the pitfall above. Wrap the listener or callback with AsyncResource.bind(fn) at registration time so it captures the current context.

Logs show no correlationId at all. Confirm the logger uses the mixin from Step 4 and imports getContext from the same context.ts module. Two different AsyncLocalStorage instances (for example, from a duplicated module) don't share a store, so the mixin reads an empty one.

error TS5097 on import './context.ts'. The message — "An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled" — means that compiler option is off. Step 1's tsconfig.json turns it on, and TypeScript then also needs one of noEmit, emitDeclarationOnly, or rewriteRelativeImportExtensions set (Step 1 uses noEmit, since we run with tsx and never emit). For a production tsc build that emits JavaScript, enable rewriteRelativeImportExtensions or use .js import specifiers instead.

A background job or setInterval created at startup has no context. It never entered a request's run(). If a scheduled task needs an ID, start its own context with runWithContext({ correlationId: randomUUID(), startedAt: Date.now() }, task).

Next steps and further reading

Once the ID flows through your logs, filtering an incident down to a single request becomes one query instead of a guessing game.

Footnotes

  1. Node.js Documentation, "Asynchronous context tracking" — AsyncLocalStorage is part of node:async_hooks; added (experimental) in v13.10.0 / v12.17.0 and marked Stable (Stability 2) in v16.4.0. https://nodejs.org/api/async_context.html 2 3

  2. Versions verified against the npm registry on 2026-07-01: pino 10.3.1, express 5.2.1 (engines node >= 18), typescript 6.0.3, tsx 4.22.4, @types/node 26.0.1, @types/express 5.0.6. https://www.npmjs.com/package/pino 2

  3. Node.js Documentation, asyncLocalStorage.enterWith() / run() — "run() should be preferred over enterWith()" because a context entered with enterWith() does not automatically exit. https://nodejs.org/api/async_context.html#asynclocalstorageenterwithstore

  4. Node.js Documentation, crypto.randomUUID([options]) returns a random RFC 4122 version 4 UUID. https://nodejs.org/api/crypto.html#cryptorandomuuidoptions

  5. pino Documentation, mixin option — "called each time one of the active logging methods is called," and "the properties of the returned object will be added to the logged JSON." https://github.com/pinojs/pino/blob/main/docs/api.md

  6. Express 5 Migration Guide, "Rejected promises handled from middleware and handlers" — "handlers that return rejected promises are now handled by forwarding the rejected value as an Error to the error handling middleware." Verified empirically on Express 5.2.1. https://expressjs.com/en/guide/migrating-5.html

  7. Node.js Documentation, "Integrating AsyncResource with EventEmitter" and AsyncResource.bind(fn) — binds a function to the currently active execution context. https://nodejs.org/api/async_context.html#integrating-asyncresource-with-eventemitter

  8. Node.js v24.0.0 release notes — AsyncLocalStorage now uses AsyncContextFrame as its default implementation. https://nodejs.org/en/blog/release/v24.0.0