Correlation IDs across services
When a user clicks a button in your frontend and that request bounces through five microservices before landing in a database, you need one thread connecting every log, every error, and every retry. Correlation IDs are that thread—a unique identifier you generate once and carry through your entire system. In this post, I'll show you how to implement correlation IDs in NestJS so that debugging production issues becomes detective work instead of chaos.
Why correlation IDs matter in distributed systems
I learned this the hard way. Two years ago, a customer reported a timeout during checkout on our e-commerce platform. I had logs from the payment service, logs from the inventory service, logs from the order service—but no way to know which log entries belonged to that specific request. I spent four hours stitching together timestamps and user IDs. Today I'd solve it in four minutes with a correlation ID.
In a monolith, you can trace a request through the call stack. In microservices, that stack is distributed across machines, cloud regions, and log aggregators. A correlation ID is your lifeline. It travels with every HTTP call, every message on Kafka, every database transaction. When you grep for that ID, you see the entire journey.
Beyond debugging, correlation IDs help with:
- Rate limiting and quota tracking: Know which external customer caused the spike.
- Cost allocation: Trace which service consumed the most resources for a given request.
- Audit trails: Connect changes across services to a single user action.
- Performance analysis: See where a request spent time across the entire stack.
Accept x-request-id or generate one
The journey starts at your API gateway or first NestJS service. You check whether the incoming request carries an x-request-id header. If it does, you trust it and pass it forward. If not, you generate a fresh UUID. This way, requests that originate outside your system (from another company's API, a mobile app, a third-party integration) can bring their own ID, while internal requests always get one.
Here's a middleware that does this:
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const correlationId = req.headers['x-request-id'] || randomUUID();
req.correlationId = correlationId;
res.setHeader('x-request-id', correlationId);
next();
}
}
Attach this middleware to all routes in your main.ts or inside your app module. The key moves here: we read the header if present, generate a UUID if not, store it on the request object, and echo it back in the response. The caller now sees the ID, even if they didn't send one, and can use it for follow-up requests or debugging.
Store it in AsyncLocalStorage
Middleware gives you the ID, but how do you access it deep inside your service logic without threading it through every function parameter? That's where AsyncLocalStorage comes in. It's a Node.js primitive that stores context per async execution flow—perfect for correlation IDs.
Create a custom service to manage this:
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
@Injectable()
export class CorrelationIdService {
private readonly store = new AsyncLocalStorage<string>();
set(id: string): void {
this.store.run(id, () => {});
}
get(): string | undefined {
return this.store.getStore();
}
}
This won't work as-is because AsyncLocalStorage.run() only keeps the store alive within the callback. For NestJS, you need a slightly different pattern using a helper function:
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
@Injectable()
export class CorrelationIdService {
private readonly store = new AsyncLocalStorage<string>();
run<T>(id: string, callback: () => T): T {
return this.store.run(id, callback);
}
get(): string | undefined {
return this.store.getStore();
}
}
Then update your middleware to use it:
@Injectable()
export class CorrelationIdMiddleware implements NestMiddleware {
constructor(private correlationIdService: CorrelationIdService) {}
use(req: Request, res: Response, next: NextFunction) {
const correlationId = req.headers['x-request-id'] || randomUUID();
res.setHeader('x-request-id', correlationId);
this.correlationIdService.run(correlationId, () => {
next();
});
}
}
Now, from anywhere in your dependency injection tree—a service, a controller, a logger—you can call this.correlationIdService.get() and retrieve the ID without passing it through function signatures.
Forward it on outgoing HTTP calls and messages
A correlation ID is worthless if you don't forward it. When your order service calls the payment service, the payment service must receive the same ID. When you publish an event to Kafka, that ID must travel in the message headers.
For HTTP calls, create an axios interceptor (or use HttpModule in NestJS):
import { Injectable } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { CorrelationIdService } from './correlation-id.service';
@Injectable()
export class HttpClientService {
constructor(
private httpService: HttpService,
private correlationIdService: CorrelationIdService,
) {
this.setupInterceptor();
}
private setupInterceptor() {
this.httpService.axiosRef.interceptors.request.use((config) => {
const correlationId = this.correlationIdService.get();
if (correlationId) {
config.headers['x-request-id'] = correlationId;
}
return config;
});
}
}
Use this service instead of raw axios, and every outgoing HTTP call automatically carries the correlation ID.
For Kafka messages, add the ID to the message headers:
@Injectable()
export class OrderService {
constructor(
private kafkaService: KafkaService,
private correlationIdService: CorrelationIdService,
) {}
async publishOrderCreated(orderId: string) {
const correlationId = this.correlationIdService.get();
await this.kafkaService.emit('order.created', {
orderId,
headers: {
'x-correlation-id': correlationId,
},
});
}
}
On the consuming end, extract it and set it in your context before processing the message.
Include it in error responses
When something goes wrong, your API response should include the correlation ID. The caller (frontend, mobile app, support team) can immediately provide it to you, and you can pull up the entire request trace.
Create a global exception filter:
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { Response } from 'express';
import { CorrelationIdService } from './correlation-id.service';
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
constructor(private correlationIdService: CorrelationIdService) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const res = ctx.getResponse<Response>();
const status = exception instanceof HttpException
? exception.getStatus()
: 500;
const correlationId = this.correlationIdService.get();
res.status(status).json({
statusCode: status,
correlationId,
message: exception instanceof Error ? exception.message : 'Internal server error',
timestamp: new Date().toISOString(),
});
}
}
Now every error includes the ID. A customer on your support chat can say "I got an error with correlation ID xyz123" and you immediately know which logs to pull.
Common mistakes
Forgetting to forward the ID on async operations: If you spawn a background job or schedule a task, the AsyncLocalStorage context is lost. Capture the correlation ID before the async boundary and pass it explicitly.
Trusting untrusted sources: If you accept correlation IDs from external callers without validation, a malicious actor could inject non-UUID strings or SQL injection payloads. Either generate your own or validate the format strictly.
Not including it in database queries: Log the correlation ID as part of your query context (in a comment or a structured logging field). This helps when you review slow query logs later.
Using correlation IDs only in logs: Include them in metrics, traces (OpenTelemetry), and APM tools. A correlation ID is only useful if your entire observability stack knows about it.
Key takeaways
- Accept an
x-request-idheader from callers or generate a UUID in middleware—echo it back in the response. - Store the ID in
AsyncLocalStorageso services can retrieve it without thread-passing through every function. - Attach the correlation ID to all outgoing HTTP requests and message headers automatically via interceptors.
- Include the correlation ID in error responses so callers can provide it to support and you can trace the issue instantly.
- Integrate correlation IDs into your logging, metrics, and tracing infrastructure—they're only valuable if every tool knows about them.






Comments
Loading comments…