Request Correlation IDs
Trace a single user request across logs and services with x-request-id - accept incoming IDs, generate when missing, and forward to downstream calls.
Recipe
Quick-reference recipe card - copy-paste ready.
import { randomUUID } from "node:crypto";
function getRequestId(header: string | string[] | undefined): string {
const raw = Array.isArray(header) ? header[0] : header;
return raw && raw.length <= 128 ? raw : randomUUID();
}
// Middleware
app.use((req, res, next) => {
const requestId = getRequestId(req.headers["x-request-id"]);
req.headers["x-request-id"] = requestId;
res.setHeader("x-request-id", requestId);
req.log = logger.child({ requestId });
next();
});When to reach for this:
- Debugging "which logs belong to this 500 error?"
- Support tickets with a single ID from response headers.
- Distributed tracing before full OpenTelemetry rollout.
- Correlating API logs with worker queue job logs.
Working Example
import express from "express";
import pino from "pino";
import { randomUUID } from "node:crypto";
const logger = pino({ level: "info" });
const app = express();
app.use(express.json());
app.use((req, res, next) => {
const incoming = req.headers["x-request-id"];
const requestId = typeof incoming === "string" && incoming.length <= 128
? incoming
: randomUUID();
res.setHeader("x-request-id", requestId);
(req as express.Request & { log: pino.Logger; requestId: string }).log =
logger.child({ requestId });
(req as express.Request & { requestId: string }).requestId = requestId;
req.log.info({ event: "request_start", method: req.method, path: req.path });
next();
});
async function fetchBillingProfile(requestId: string, userId: string) {
const res = await fetch(`https://billing.internal/users/${userId}`, {
headers: { "x-request-id": requestId },
signal: AbortSignal.timeout(3_000),
});
return res.json();
}
app.get("/users/:id/summary", async (req, res) => {
const billing = await fetchBillingProfile(req.requestId, req.params.id);
req.log.info({ event: "summary_built", billingStatus: billing.status });
res.json({ userId: req.params.id, billing });
});
app.listen(3000);What this demonstrates:
- Incoming
x-request-idpreserved when present and bounded (128 chars). - Response echoes the same ID for client/support correlation.
- Outbound
fetchforwards the ID to internal services. - All logs in the request share
requestIdvia child logger.
Deep Dive
How It Works
- Correlation ID is an opaque string - UUID v4 is the common default.
- Edge gateway (nginx, ALB, Cloudflare) may inject
x-request-idfirst. - Downstream services should trust but validate format/length, not content.
- OpenTelemetry
trace_idcan coexist - many teams mapx-request-idto trace root or baggage.
Header Conventions
| Header | Use |
|---|---|
x-request-id | De facto standard for HTTP APIs |
traceparent | W3C trace context (OTel) |
x-correlation-id | Legacy enterprise systems |
Pick one primary ID in logs; support both headers at edge during migration.
Fastify
const app = Fastify({
genReqId: (req) => req.headers["x-request-id"] as string ?? randomUUID(),
requestIdHeader: "x-request-id",
requestIdLogLabel: "requestId",
});- Built-in
req.idandreq.log- less middleware boilerplate.
Queue Workers
await queue.add("send-email", payload, {
headers: { "x-request-id": req.requestId },
});- Propagate ID in job payload or BullMQ job opts for end-to-end traces.
Gotchas
- Generating new ID on every internal hop - breaks correlation. Fix: forward incoming ID.
- Unbounded header values - log injection / storage abuse. Fix: max length 64-128, alphanumeric + hyphen.
- Not returning ID in response - support cannot correlate. Fix:
res.setHeader("x-request-id", id). - Logging ID only at start - deep errors lose context. Fix: child logger on
req.log. - Trusting client ID for auth - ID is not a secret. Fix: auth via JWT/session separately.
- Missing ID in async fire-and-forget -
setImmediatelosesreq. Fix: AsyncLocalStorage or capture ID in closure.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| x-request-id | Simple log correlation | Need span parent/child timing |
| OpenTelemetry trace_id | Full distributed tracing | Minimal MVP API |
| AWS X-Ray trace header | AWS-native stack | Multi-cloud OTel standard |
| Custom header per org | Legacy enterprise mandate | Greenfield public API |
FAQs
UUID v4 vs ULID?
ULIDs are sortable by time - helpful in log indexes. UUID v4 is universally recognized. Either works if documented.
Should gateway or app generate ID?
Gateway if present - consistent across microservices. App generates when gateway does not.
How does this relate to trace_id?
Log both during OTel migration: logger.child({ requestId, trace_id }) from active span context.
GraphQL correlation?
Same middleware - one ID per HTTP request regardless of query count.
Multiple parallel outbound calls?
Same x-request-id on all parallel fetches from one inbound request. Child spans differentiate in OTel.
NestJS?
nestjs-pino supports genReqId. Or middleware + CLS (continuation-local-storage).
Log aggregation query?
requestId:"abc-123" in Datadog/Loki - field must be top-level JSON key every line.
Health check IDs?
Still fine to generate - low volume. Or skip logging health entirely.
Related
- Logging Basics - structured fields
- pino - child loggers
- AsyncLocalStorage for Context - implicit propagation
- OpenTelemetry Node SDK - trace context
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.