Coding

AsyncLocalStorage request IDs: follow one request through a Node.js API

Put one ID on every log line, response and outbound call without threading it through every function.

AsyncLocalStorage request IDs on every line. Cover graphic for a guide to adding request IDs to Node.js logs with AsyncLocalStorage.

A customer emails: “checkout failed around 2pm”. You open the logs and find four hundred lines from 2pm, interleaved from fifty concurrent requests, with nothing tying them together. The fix is an AsyncLocalStorage request ID: generate one ID per request, store it in AsyncLocalStorage, and have your logger read it so every line carries it automatically.

In short: create the ID in your first middleware, run the rest of the request inside als.run(), add the ID to log lines with a Pino mixin, return it in an x-request-id response header, and forward it on outbound calls. That is an afternoon of work for a 1-person team, and it pays off the first time someone quotes an ID from an error response. If you haven’t settled on a logger yet, start with structured logging with Pino.

What a request ID needs to do

Job Why it matters
Appear on every log line for the request Including the database layer and error handler, not just the route
Come back in a response header Users and support tickets can quote it
Travel to downstream services You can follow the request into the next service’s logs
Be validated if accepted from a header Otherwise anyone can write arbitrary strings into your logs

The first job is the hard one. Threading a requestId argument through every function works for a week, then someone adds a helper that doesn’t take it.

How AsyncLocalStorage works in Node.js

AsyncLocalStorage (from node:async_hooks) gives you a store that follows asynchronous execution. Call als.run(store, fn), and anything fn triggers, including awaited promises, timers and callbacks scheduled inside it, sees the same store via als.getStore(). Concurrent requests each get their own. On Node.js 24, now the Active LTS line, it uses the AsyncContextFrame implementation by default, which the release notes describe as more efficient. The API is the same on Node 20, 22 and 24, so you don’t need to upgrade to use it (though the Node 24 LTS upgrade is worth planning anyway).

How to add an AsyncLocalStorage request ID in Express

Step 1: a small context module

js
// context.js
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

export const als = new AsyncLocalStorage();
const VALID_ID = /^[A-Za-z0-9._-]{8,64}$/;

export function newRequestId(incoming) {
  return typeof incoming === 'string' && VALID_ID.test(incoming)
    ? incoming
    : randomUUID();
}

export const getContext = () => als.getStore() ?? {};
export const getRequestId = () => getContext().requestId;

The regex is deliberately boring: UUIDs and most load balancer IDs pass, newlines and 10 KB strings don’t.

Step 2: one middleware at the top

js
// app.js
import express from 'express';
import { als, newRequestId } from './context.js';

const app = express();

app.use((req, res, next) => {
  const requestId = newRequestId(req.get('x-request-id'));
  res.set('x-request-id', requestId);
  als.run({ requestId }, next);
});

app.use(express.json());
app.get('/orders/:id', async (req, res) => {
  res.json(await loadOrder(req.params.id));
});

app.listen(3000);

Everything after next runs inside the context. Express 5 forwards rejected promises from async handlers to your error middleware, so error log lines carry the ID too.

Step 3: make the logger read the context

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

export const logger = pino({
  mixin() {
    const { requestId, userId } = getContext();
    return requestId ? { requestId, userId } : {};
  },
});

A repository function deep in the stack now logs with the ID attached, without knowing anything about HTTP:

text
{"level":30,"requestId":"5f0c2a1e-3b7d-4c55-9a51-0f4e2b8d6c19","orderId":"8812","ms":14,"msg":"order loaded"}

After authentication, getContext().userId = user.id puts the user on every later line. Mutating the store is fine; it belongs to this request only.

Request IDs in Fastify: built-in IDs plus request context

Fastify gives every request request.id and a child logger, request.log, that includes it as reqId. Two details in Fastify v5:

  • requestIdHeader is false by default, so incoming request-id headers are ignored and genReqId is called. The default generator produces sequential IDs per process, which collide across instances.
  • request.log only exists where request does. Your data layer usually doesn’t have it.

Set genReqId yourself and use @fastify/request-context, which is built on AsyncLocalStorage, for code with no request in scope:

js
import Fastify from 'fastify';
import { fastifyRequestContext, requestContext } from '@fastify/request-context';
import { newRequestId } from './context.js';

const app = Fastify({
  logger: true,
  genReqId: (req) => newRequestId(req.headers['x-request-id']),
});
await app.register(fastifyRequestContext);

app.addHook('onRequest', async (request, reply) => {
  request.requestContext.set('requestId', request.id);
  reply.header('x-request-id', request.id);
});

// anywhere in the call tree:
export const currentRequestId = () => requestContext.get('requestId');

How to forward the request ID to other services

js
import { getRequestId } from './context.js';

export function apiFetch(url, init = {}) {
  const headers = new Headers(init.headers);
  const id = getRequestId();
  if (id && !headers.has('x-request-id')) headers.set('x-request-id', id);
  return fetch(url, { ...init, headers });
}

The receiving service runs the same middleware and its logs line up with yours. A 1-person team with one service can skip this until a second service exists; a 10-person team with several services should do it on day one.

Why AsyncLocalStorage loses context

  • Library callback queues. Some older libraries queue callbacks and invoke them from a different async context. If getRequestId() returns undefined in a callback, wrap it with AsyncResource.bind(fn) from node:async_hooks.
  • Listeners registered at startup run in the boot context. Put the ID in the event payload.
  • enterWith() sets the store with no clear end. Prefer run().
  • Background jobs run later, often in another process. Copy the ID into the job payload.

A test catches regressions:

js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { als, getRequestId } from './context.js';

test('request id survives awaits and timers', async () => {
  await als.run({ requestId: 'test-req-0001' }, async () => {
    await new Promise((r) => setTimeout(r, 5));
    assert.equal(getRequestId(), 'test-req-0001');
  });
});

If you only do one thing

Add the Step 2 middleware and the Pino mixin, and return x-request-id on every response, errors included. The rest (forwarding, job payloads, Fastify context) can follow when you need it.

FAQ

Is AsyncLocalStorage slow?

It adds some overhead per async operation, and Node 24 switched to a more efficient implementation by default. For a typical API doing database and network I/O, request handling time is dominated by that I/O, not by context tracking.

Should I accept a request ID from the client?

Accept it from a proxy or load balancer you control, and validate the format either way. Never write an unvalidated header straight into logs.

Request ID vs trace ID: do I need both?

A trace ID from OpenTelemetry’s traceparent header is built for tracing tools. A request ID is short, human-quotable and safe to show a customer. Many teams keep both.

Doing this with ApiTraffic

If you’d rather not stitch request logs together yourself, ApiTraffic records every inbound request and response from one middleware (method, path, status, latency, headers, bodies) and logs outbound requests too. Tag the request ID and user so you can find them later:

js
app.use(express.json());
app.use(apitraffic.express());
app.use((req, res, next) => {
  apitraffic.tag('requestId', res.get('x-request-id'));
  if (req.user) apitraffic.tag('userId', String(req.user.id));
  next();
});

Then search for what a customer reported, for example tag:userId == "1042" AND status_code >= 400. With the APM preload (node -r @apitraffic/node/register ./server.js), each request’s waterfall shows the queries and outbound calls it made.

Start free and see every request your API handles: apitraffic.io

Sources: Node.js v24.0.0 release notes, May 6, 2025; Node.js AsyncLocalStorage docs; Fastify v5 Server reference, requestIdHeader and genReqId

Keep reading