All posts
18 Aug 2026

Tracing Requests Across Microservices: A Practical Guide to Correlation IDs in Node.js

A hands-on, code-heavy guide to implementing distributed tracing and propagating correlation IDs across asynchronous Node.js boundaries and HTTP services.

Tracing Requests Across Microservices: A Practical Guide to Correlation IDs in Node.js

Transitioning from a monolithic application to a microservices architecture brings undeniable benefits in scalability and team autonomy. However, it introduces a notorious debugging challenge: when an API call fails deep down a chain of five services, how do you trace the lifecycle of that single user request across multiple logs, databases, and network boundaries?

The answer lies in Distributed Tracing and Correlation IDs (often referred to as Request IDs).

In this practical guide, we will explore how to generate, propagate, and maintain correlation IDs across asynchronous boundaries in Node.js, ensuring your log aggregation tools give you a unified view of every transaction.


The Anatomy of Distributed Tracing

A correlation ID is a unique identifier (usually a UUID v4) attached to an incoming request at the edge of your architecture (like an API Gateway). As that request fans out to downstream services via HTTP, gRPC, or message queues, the correlation ID is passed along in metadata or headers. Every log entry produced while handling that request includes the ID, allowing engineers to query centralized logging systems (like ELK, Loki, or Datadog) and instantly reconstruct the request’s complete journey.

code
[Client] 
   │ 
   ▼ HTTP Request
[API Gateway] ──(Generates X-Correlation-ID: uuid-123)
   │ 
   ├─► [Auth Service]     (Logs with uuid-123)
   │ 
   └─► [Order Service]    (Logs with uuid-123)
          │
          ▼ HTTP Request (Passes X-Correlation-ID: uuid-123)
       [Payment Service]  (Logs with uuid-123)

The Node.js Asynchronous Context Challenge

In traditional multi-threaded languages like Java or Go, thread-local storage makes passing request-scoped data relatively straightforward. Node.js, however, is single-threaded and heavily asynchronous. Operations yield control back to the event loop constantly, meaning multiple concurrent requests share the exact same execution thread.

If you simply store a correlation ID in a global variable or a module-level variable, it will be overwritten by the next incoming request milliseconds later.

To solve this, Node.js provides the node:async_hooks module, and more specifically, the AsyncLocalStorage API. This allows us to create an asynchronous storage namespace where data persists throughout the entire async lifecycle of a specific request.


Step 1: Setting Up AsyncLocalStorage

Let’s create a dedicated tracing utility that initializes our storage context and exposes helper functions to get and set the current correlation ID.

// tracing.js
const { AsyncLocalStorage } = require('async_hooks');
const { v4: uuidv4 } = require('uuid');

const asyncLocalStorage = new AsyncLocalStorage();

const TRACE_HEADER = 'x-correlation-id';

/**
 * Middleware to capture or generate a correlation ID
 */
function correlationMiddleware(req, res, next) {
  let correlationId = req.headers[TRACE_HEADER];

  if (!correlationId) {
    correlationId = uuidv4();
  }

  // Attach to response headers so the client can see it
  res.setHeader(TRACE_HEADER, correlationId);

  // Run the rest of the request lifecycle within the async context
  asyncLocalStorage.run({ correlationId }, () => {
    next();
  });
}

/**
 * Retrieve the current correlation ID from anywhere in the call stack
 */
function getCorrelationId() {
  const store = asyncLocalStorage.getStore();
  return store ? store.correlationId : 'unknown';
}

module.exports = {
  correlationMiddleware,
  getCorrelationId,
  TRACE_HEADER,
};

Step 2: Integrating with Express and Loggers

Next, we wire up our middleware to an Express application and integrate it with a structured logging library like pino or winston. Structured logging (JSON format) is critical here because log aggregators can automatically index the correlation ID field.

// server.js
const express = require('express');
const pino = require('pino')();
const { correlationMiddleware, getCorrelationId } = require('./tracing');
const orderService = require('./orderService');

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

// Register correlation ID middleware early
app.use(correlationMiddleware);

// Custom logger wrapper that automatically injects the correlation ID
const logger = {
  info: (msg, data = {}) => {
    pino.info({ correlationId: getCorrelationId(), ...data }, msg);
  },
  error: (msg, data = {}) => {
    pino.error({ correlationId: getCorrelationId(), ...data }, msg);
  }
};

app.post('/orders', async (req, res) => {
  logger.info('Received request to create order', { body: req.body });

  try {
    const order = await orderService.createOrder(req.body);
    logger.info('Order successfully created', { orderId: order.id });
    return res.status(201).json(order);
  } catch (err) {
    logger.error('Failed to create order', { error: err.message });
    return res.status(500).json({ error: 'Internal Server Error' });
  });
});

app.listen(3000, () => {
  console.log('Order Service running on port 3000');
});

Step 3: Propagating IDs to Downstream Services

When your microservice needs to call another internal service (e.g., calling a Payment Service from the Order Service), you must manually extract the current correlation ID and inject it into the outgoing HTTP headers.

Using modern fetch or HTTP clients like axios, we can wrap our outbound requests to ensure context is never dropped.

// orderService.js
const axios = require('axios');
const { getCorrelationId, TRACE_HEADER } = require('./tracing');

// Create an Axios instance configured for internal microservice communication
const paymentClient = axios.create({
  baseURL: 'http://payment-service.internal',
});

// Axios Interceptor to automatically inject correlation ID on every request
paymentClient.interceptors.request.use((config) => {
  const correlationId = getCorrelationId();
  if (correlationId) {
    config.headers[TRACE_HEADER] = correlationId;
  }
  return config;
});

async function createOrder(orderData) {
  // 1. Process local order logic...
  const dummyOrder = { id: 'ord_98765', ...orderData };

  // 2. Call downstream Payment Service
  // The interceptor automatically attaches the 'x-correlation-id' header
  await paymentClient.post('/charge', {
    orderId: dummyOrder.id,
    amount: orderData.amount,
  });

  return dummyOrder;
}

module.exports = { createOrder };

Step 4: Handling Asynchronous Boundaries & Message Queues

HTTP requests are straightforward because middleware runs naturally in the request lifecycle. However, what happens when you publish an event to RabbitMQ, Kafka, or AWS SQS?

When publishing a message, you must serialize the correlation ID into the message payload or metadata headers:

// publisher.js
const { getCorrelationId } = require('./tracing');

async function publishOrderEvent(channel, order) {
  const correlationId = getCorrelationId();

  const messagePayload = {
    event: 'OrderCreated',
    data: order,
  };

  // Publish with headers/metadata
  channel.sendToQueue(
    'order_queue',
    Buffer.from(JSON.stringify(messagePayload)),
    {
      headers: {
        'x-correlation-id': correlationId,
      },
    }
  );
}

When a worker consumes this message from the queue, it must extract that header and wrap its execution handler inside AsyncLocalStorage.run() just like an HTTP middleware does:

// consumer.js
const { AsyncLocalStorage } = require('async_hooks');
const asyncLocalStorage = new AsyncLocalStorage();

function consumeMessage(msg) {
  const headers = msg.properties.headers || {};
  const correlationId = headers['x-correlation-id'] || uuidv4();

  asyncLocalStorage.run({ correlationId }, async () => {
    const content = JSON.parse(msg.content.toString());
    console.log(`Processing event ${content.event}`);
    // All internal loggers and downstream calls will now inherit this correlationId
  });
}

Best Practices and Common Pitfalls

  1. Standardize the Header Name: Agree on a single header across all teams and technologies in your organization. x-correlation-id or x-request-id are industry standards.
  2. Never Trust Client Inputs Blindly: While accepting client-provided correlation IDs is useful for end-to-end user support, ensure you sanitize or validate the format (e.g., verifying it’s a valid UUID) to prevent log injection attacks.
  3. Propagate Beyond HTTP: Don’t forget background workers, cron jobs, gRPC metadata, and WebSocket frames. Every context switch requires explicit propagation.
  4. Leverage OpenTelemetry: While writing custom AsyncLocalStorage code is great for understanding internals, production-grade systems should eventually adopt OpenTelemetry (OTel). OTel standardizes distributed tracing, automatically instruments popular Node.js libraries (Express, HTTP, pg, redis), and natively handles context propagation.

Conclusion

Implementing correlation IDs in a Node.js microservices architecture bridges the gap between asynchronous event loops and synchronous debugging logic. By pairing Node.js’s native AsyncLocalStorage with disciplined HTTP header and message metadata propagation, you transform isolated error logs into a cohesive, searchable timeline. Your future self—debugging a production outage at 2 AM—will thank you.

More posts