Stop Passing Request IDs Through Every Function

Node.js has a built-in way to maintain state across async calls without polluting your function signatures.

Share

You need to log a trace ID. So you pass it from the route handler, to the service layer, to the database repository, and finally to the logger. Suddenly, half your functions accept a context object just to keep logging alive.

It is a familiar pain. But Node.js has a built-in mechanism to fix it.

Why this matters

Contextual data—like trace IDs, user IDs, or tenant IDs—shouldn't bleed into your business logic. You want this data available anywhere in the request lifecycle without explicitly passing it through every function call.

Node.js solves this natively with AsyncLocalStorage.

How it works

Available via the node:async_hooks module, AsyncLocalStorage creates a state bubble that survives the Node.js event loop. You initialize a store, wrap your request execution in a .run() method, and any asynchronous logic triggered within that block can query the store.

Think of it as thread-local storage, but specifically designed for JavaScript's single-threaded, asynchronous architecture.

Where this helps

  • Structured logging: Automatically attach request or correlation IDs to every log entry without changing logger signatures.
  • Multi-tenancy: Select the correct database schema or connection pool deep in your data layer based on the incoming request origin.
  • Feature flags: Evaluate flags contextually without threading user profiles through your service constructors.

Watch out

Use this strictly for ambient context. If your core business logic breaks because it couldn't read from the store, your architecture is wrong. Also, while performance has drastically improved in recent Node.js releases, mismanaging these contexts can still introduce subtle memory leaks or lost contexts in unhandled promise rejections.

Try it yourself

Here is how you can share a request ID across an async stack in a vanilla Node.js HTTP server:

import http from 'node:http';
import { AsyncLocalStorage } from 'node:async_hooks';

// 1. Initialize the store
const requestContext = new AsyncLocalStorage();

http.createServer((req, res) => {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();

  // 2. Wrap the request lifecycle in .run()
  requestContext.run({ requestId }, () => {
    // Simulating a deeply nested async operation
    setTimeout(() => {
      fetchUserData();
    }, 100);
  });

  function fetchUserData() {
    // 3. Retrieve the context anywhere down the line
    const { requestId } = requestContext.getStore() || {};
    console.log(`[${requestId}] Finished fetching data`);
    res.end(`Request handled: ${requestId}`);
  }
}).listen(3000);

TL;DR

  • What changed: Node's AsyncLocalStorage maintains state across async boundaries globally.
  • Why it matters: It eliminates the need to pass trace IDs or request contexts through every function signature.
  • What to try today: Wrap your API entry points in .run() and kill your manual context passing.