Home / Articles

Correlating Logs Across Async Calls with AsyncLocalStorage

Learn how Node.js AsyncLocalStorage tracks per-request context like requestId across await boundaries without manually threading it through every function.

Publisher: REACTAPP.TOP6 min read1412 words

This article is published in English.

Sources: The Node.js Feature That Replaces Passing `requestId` Through Every Single Function

Every backend developer eventually runs into this exact scenario: a requestId gets created when a request comes in, and somewhere six layers deep, inside a database helper or a shared logging function, you need that same identifier so you can tie a log entry back to the request that triggered it. The obvious solution is to pass the ID as an argument through every function in the chain. It works, but it's painful to maintain, because now every intermediate function carries a parameter it has no use for beyond handing it off to the next call.

The Naive Fixes, and Why They Both Fail

It's tempting to reach for a simple module-level variable, and that instinct is wrong in a way that won't show up until you're running in production:

let currentRequestId = null;

app.use((req, res, next) => {
  currentRequestId = req.headers["x-request-id"];
  next();
});

function logSomething(message) {
  console.log(`[${currentRequestId}] ${message}`); // works... until it doesn't
}

This approach seems reasonable if you imagine only one request being processed at a time, but it collapses once you account for how Node actually handles concurrency. Node serves many requests "simultaneously" by interleaving their execution around await points rather than running them strictly one after another. Request A writes to currentRequestId, then hits an await and yields control back to the event loop, which may immediately start processing request B and overwrite that same variable with B's ID. By the time request A's code resumes, currentRequestId is pointing at B, and from that moment forward every log statement in request A is quietly, confidently attributing the wrong ID.

AsyncLocalStorage: Context That Follows Execution, Not the Call Stack

AsyncLocalStorage, shipped as part of Node's built-in async_hooks module, is designed to solve precisely this problem. It keeps a distinct value for each logical chain of asynchronous execution, and it does so correctly even while requests interleave across await calls, promise chains, and callbacks.

const { AsyncLocalStorage } = require("async_hooks");
const requestContext = new AsyncLocalStorage();

app.use((req, res, next) => {
  const store = { requestId: req.headers["x-request-id"] || crypto.randomUUID() };
  requestContext.run(store, next); // everything inside next() runs "inside" this context
});

function logSomething(message) {
  const store = requestContext.getStore();
  console.log(`[${store?.requestId}] ${message}`); // correct, regardless of interleaving
}

Calling requestContext.run(store, next) does more than assign a value, it opens a context that automatically flows through everything invoked from inside that call, whether the execution is synchronous or asynchronous, including code that continues after an await, runs inside a .then() handler, or fires later as a callback from some I/O operation. Even deep inside a database query helper defined in a completely separate file, calling requestContext.getStore() returns the context belonging to the correct request, never one borrowed from whichever request last happened to execute. That's because the context is bound to the specific asynchronous execution chain making the call, not to a single shared variable that every request writes into.

Why This Actually Works Across await Boundaries

This is the property that sets AsyncLocalStorage apart from an ordinary variable, and it ties directly into how async/await operates under the hood. Each time an async function suspends at an await and later picks back up, Node keeps track of which asynchronous context that resumption is associated with, and restores it automatically before the continuation runs:

app.get("/orders/:id", async (req, res) => {
  requestContext.run({ requestId: req.headers["x-request-id"] }, async () => {
    logSomething("fetching order"); // context is present
    const order = await db.getOrder(req.params.id); // pauses, other requests may run here
    logSomething("order fetched"); // context is STILL correctly present after resuming
    res.json(order);
  });
});

Between the two logSomething calls, the event loop was completely free to run other requests' code in the meantime, yet none of that interleaving corrupted which context this particular continuation sees once it resumes. This is exactly the guarantee that the naive shared variable couldn't provide: correctness that holds up under the very kind of interleaved execution that Node's concurrency model actively promotes.

The Real Payoff: Structured Logging Without Threading Parameters Everywhere

This is the point where the added complexity of AsyncLocalStorage pays off in a real application: you can build a logger that automatically attaches the request's context to every call, with no calling code anywhere needing to pass a parameter for it:

function createLogger() {
  return {
    info(message, extra = {}) {
      const store = requestContext.getStore();
      logger.info({ requestId: store?.requestId, ...extra }, message);
    },
  };
}

// deep inside some completely unrelated file, five layers into the call stack
const log = createLogger();
log.info("Payment processed", { amount: 4999 }); // requestId is present, automatically

Nothing in processPayment's function signature had to change to make this work. It never accepts a requestId argument and never passes one along to anything it calls, yet every log line it emits is correctly tagged with the request responsible for it. That's the real benefit here: request-scoped data becomes available anywhere in the call chain without cluttering every function signature between where the request begins and wherever that data actually gets used.

The Boundary That Trips People Up

The context you establish inside .run() only reaches code that's actually part of that same execution chain: anything invoked from within the callback, whether directly or indirectly, including through chains of awaited promises. It does not extend backward to code that executed before .run() was invoked, and it doesn't extend forward into work that genuinely detaches itself from that chain:

app.use((req, res, next) => {
  requestContext.run({ requestId: req.headers["x-request-id"] }, () => {
    setTimeout(() => {
      logSomething("delayed log"); // still correct — setTimeout inside the context stays tracked
    }, 1000);
    next();
  });
});

Calling setTimeout from inside the context still works as expected, since Node's async_hooks propagation mechanism covers timers, promises, and nearly every standard async primitive you'd encounter. Where things actually fall apart is with fire-and-forget work that's intentionally designed to outlive the request that triggered it. A task pushed onto a separate, independent worker process is a good example: there's no execution chain linking it back to the context it originated from, because it isn't a continuation of the same asynchronous sequence of calls at all. It's a distinct, unrelated one.

The Honest Performance Note

AsyncLocalStorage isn't free. Capturing and restoring context across every async transition takes real work, and older versions of Node handled this considerably less efficiently than the runtime does today. In current Node releases, the overhead for a typical web application workload is small enough that it won't matter for the vast majority of projects, and the gains in correctness and cleaner code are almost always worth that small cost. If you're running at an extreme scale where every microsecond per request counts, it's worth benchmarking your specific case. But for nearly every backend service, treating this as a safe default is reasonable. It's not some specialized optimization trick you reach for occasionally; it's a tool with a small, known cost that pays for itself in almost all normal circumstances.

The Actual Idea Underneath This

AsyncLocalStorage shouldn't be thought of as a global variable that happens to behave politely. It's context bound structurally to one specific chain of asynchronous execution: it survives every await, every callback, and every promise resolution within that chain, while remaining completely invisible to any other chain running at the same time. Once you internalize that model, the behavior stops feeling like a trick and starts looking like exactly the right tool for one specific job: making request-scoped data reachable from anywhere inside a call chain, without forcing each function along that chain to know, or care, that the data is being passed through it at all.