---
title: Correlation IDs in Node.js with AsyncLocalStorage (2026)
description: Add a correlation ID to every log line in Node.js with built-in AsyncLocalStorage and Express — typed, zero-dependency, plus pino and downstream propagation.
url: https://nerdleveltech.com/nodejs-correlation-id-asynclocalstorage-tutorial
language: en
date: '2026-07-01'
category: backend
tags:
  - asynclocalstorage
  - correlation-id
  - node.js
  - express
  - typescript
  - pino
  - observability
  - backend
markdown_url: https://nerdleveltech.com/nodejs-correlation-id-asynclocalstorage-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/nodejs-correlation-id-asynclocalstorage-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# 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` `AsyncLocalStorage`[^als] — no third-party correlation library — on **Express 5.2.1** with **pino 10.3.1** and **TypeScript 6.0.3**.[^npm] 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.4[^als]; 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.[^npm]
- 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:

```bash
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:

```json
{
  "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.[^als] Define the store once and export small typed helpers:

```typescript
// 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.[^enterwith]

## 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()`.[^uuid] It echoes the ID back on the response and then runs the rest of the request inside the store:

```typescript
// 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.[^pino] Read the store inside it, and if a request is active, add the ID:

```typescript
// 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:

```typescript
// 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.[^express5] 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:

```typescript
// 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.[^asyncresource] Here is the pitfall and the fix side by side:

```typescript
// 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:

```text
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.[^node24]

## Verification

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

```bash
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:

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

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

```bash
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:

```text
{"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:

```bash
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

- Add the correlation ID to your metrics and traces so logs, metrics, and spans line up. See [custom Prometheus metrics in Node.js and Express](https://nerdleveltech.com/prometheus-metrics-nodejs-express-prom-client-tutorial) for the metrics half.
- Pair this with [circuit breakers in Node.js with opossum](https://nerdleveltech.com/nodejs-circuit-breaker-opossum-typescript-tutorial) so a failing dependency is both isolated and traceable by ID.
- Propagate the ID across service calls in a typed RPC setup with the patterns in the [gRPC in Node.js and TypeScript production guide](https://nerdleveltech.com/grpc-nodejs-typescript-production-guide).
- Read the official [Node.js asynchronous context tracking docs](https://nodejs.org/api/async_context.html) for `snapshot()`, `withScope()`, and the Node 24 constructor options.

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

[^als]: 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
[^npm]: 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
[^enterwith]: 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
[^uuid]: Node.js Documentation, `crypto.randomUUID([options])` returns a random RFC 4122 version 4 UUID. https://nodejs.org/api/crypto.html#cryptorandomuuidoptions
[^pino]: 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
[^express5]: 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
[^asyncresource]: 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
[^node24]: Node.js v24.0.0 release notes — `AsyncLocalStorage` now uses `AsyncContextFrame` as its default implementation. https://nodejs.org/en/blog/release/v24.0.0
