DEV Community

Tarun Koshti
Tarun Koshti

Posted on

AsyncLocalStorage in Node.js: stop passing requestId to every function

Hello folks,

Recently I used a really interesting Node.js feature: AsyncLocalStorage.

What is AsyncLocalStorage?

AsyncLocalStorage lets you pass data across functions without passing it through function arguments. It ships with Node itself, in node:async_hooks, so there is nothing to install.

But wait — we can do the same thing with a global variable! So why do we need this?

Well, yes, you can do it with a global variable. But what if your app is serving 50 concurrent users? Let's say you store the request id in one:

  • User 1 arrives → gets request id U1 → their request takes 1 second to reach the next step
  • User 2 arrives in the meantime → a new request id is generated → assigned to that same global variable → now the rest of user 1's logs point at somebody else's request
  • Make that 50 concurrent users → 50 new request ids → tracing a single request becomes impossible

Global variables are fine for project-wide constants, but not for request-specific data.

Then what's the problem with passing it through function arguments?

Let's say you want a unique request identifier in your logs. You have 20 functions in the chain, and the only place that actually needs that identifier is the logger.

Most of those functions never log anything themselves — only the ones that make the HTTP call and write to the database do. But it is still your obligation to pass the request id through every downstream function, just so it can reach the one place that needs it.

That is where AsyncLocalStorage comes in. It carries the request id in its context, and whoever needs it simply reads it from the store.

Let's understand this with an example.

Example 1: passing requestId through every function argument

Notice how the request id has to be passed to every function on the way down.

// The "before" picture: requestId is threaded through every function argument.
// Count how many of these functions accept requestId without ever reading it.

// Shape of the JSON-ish payloads we pass around.
type TObject = Record<string, string | number | object | boolean>;

// Logger function.
// This is the ONLY function that actually reads requestId. Every other
// signature below just carries it along so that it can reach this one.
async function logger(logData: TObject, requestId: string) {
  const logObject = {
    requestId,
    request: logData,
  };
  console.log(JSON.stringify(logObject));
}

// Function to handle the request.
// The request lands here first, and this is where requestId is born.
async function handleRequest(requestData: TObject) {
  const requestId = crypto.randomUUID();

  // Example function to make an HTTP call
  const apiResult = await apiCall(requestData, requestId);

  // Example function to save the data in the database
  await saveData(apiResult, requestId);
}

// Function to make the HTTP request.
// requestId is dead weight here: it is forwarded to logger(), nothing more.
async function apiCall(requestData: TObject, requestId: string) {
  // Stand-in for a real API call: wait instead of hitting the network.
  await new Promise((resolve) => setTimeout(resolve, 200));

  // Dummy API response
  const response: TObject = {
    success: true,
    data: {
      userId: 'U1',
      paymentId: 'U_P1',
      sentPayload: requestData,
    },
  };

  await logger(response, requestId);
  return response;
}

// Function to save the data in the database.
// Same story: requestId is pure pass-through.
async function saveData(apiResult: TObject, requestId: string) {
  // Stand-in for a real database write.
  await new Promise((resolve) => setTimeout(resolve, 100));

  await logger({ saved: true, record: apiResult }, requestId);
}

// Run it:  npx tsx example.ts
await handleRequest({ userId: 'U1', amount: 4999 });
Enter fullscreen mode Exit fullscreen mode

Here, just to get the request id into the logger, we had to pass it through 2 functions. Now imagine 20 functions instead — every single one of them has to accept the request id and forward it, and the moment one of them forgets, that log line loses its correlation.

Example 2: the same code with AsyncLocalStorage

Now it's time to see the real magic: how the request id travels without being passed through function arguments.

import { AsyncLocalStorage } from 'node:async_hooks';

interface Context {
  requestId: string;
}

const asyncLocalStorage = new AsyncLocalStorage<Context>();

// Read the current request's id out of the store. Exposing this accessor
// instead of the store itself keeps run() in one place.
function getRequestId(): string | undefined {
  return asyncLocalStorage.getStore()?.requestId;
}

// Shape of the JSON-ish payloads we pass around.
type TObject = Record<string, string | number | object | boolean>;

// Logger function.
// Only this function reads requestId, and it pulls it from the store instead
// of receiving it as an argument. Every other signature below stays clean.
async function logger(logData: TObject) {
  const requestId = getRequestId();
  const logObject = {
    requestId,
    request: logData,
  };
  console.log(JSON.stringify(logObject));
}

// Function to handle the request.
async function handleRequest(requestData: TObject) {
  // Example function to make an HTTP call
  const apiResult = await apiCall(requestData);

  // Example function to save the data in the database
  await saveData(apiResult);
}

// Function to make the HTTP request.
// It never takes or mentions requestId, yet its log line still carries one.
async function apiCall(requestData: TObject) {
  // Stand-in for a real API call: wait instead of hitting the network.
  await new Promise((resolve) => setTimeout(resolve, 200));

  // Dummy API response
  const response: TObject = {
    success: true,
    data: {
      userId: 'U1',
      paymentId: 'U_P1',
      sentPayload: requestData,
    },
  };

  await logger(response);
  return response;
}

// Function to save the data in the database.
// Same story: a clean signature, and a correlated log line anyway.
async function saveData(apiResult: TObject) {
  // Stand-in for a real database write.
  await new Promise((resolve) => setTimeout(resolve, 100));

  await logger({ saved: true, record: apiResult });
}

// The requestId is created once, here at the boundary. Everything called
// inside run() can read it: across awaits, through nested calls, all the way
// down to logger().
asyncLocalStorage.run(
  {
    requestId: crypto.randomUUID(),
  },
  async () => {
    await handleRequest({ userId: 'u1', paymentId: 'U_P1' });
  },
);
Enter fullscreen mode Exit fullscreen mode

Here you can see we don't pass anything through the functions. We store the requestId once in AsyncLocalStorage, and the logger picks it up at the final logging step.

Both log lines come out carrying the same id:

{"requestId":"84762d3d-5f73-428b-9995-29f9b34a477a","request":{"success":true,"data":{...}}}
{"requestId":"84762d3d-5f73-428b-9995-29f9b34a477a","request":{"saved":true,"record":{...}}}
Enter fullscreen mode Exit fullscreen mode

Compare the two signatures side by side, and that is the whole argument:

before:  logger(logData, requestId)   apiCall(requestData, requestId)   saveData(apiResult, requestId)
after:   logger(logData)              apiCall(requestData)              saveData(apiResult)
Enter fullscreen mode Exit fullscreen mode

Example 3: the same thing in Express, using express-http-context

Both examples above were plain scripts. In a real app the boundary is your HTTP layer, and there is a small package that wires exactly this up for you: express-http-context.

It is worth knowing what it actually is before you reach for it. It has no dependencies, and under the hood it is the very same AsyncLocalStorage we just used by hand — its middleware is literally asyncLocalStorage.run(new Map(), () => next()). So this is not an alternative approach, it is the same approach with the plumbing already written.

npm install express express-http-context
Enter fullscreen mode Exit fullscreen mode
// The same example as before, now wired into Express with express-http-context.
//
// In the AsyncLocalStorage version we wrote three pieces of plumbing by hand:
//   1. the store     ->  new AsyncLocalStorage<Context>()
//   2. the boundary  ->  asyncLocalStorage.run({ requestId }, ...)
//   3. the accessor  ->  getRequestId()
//
// express-http-context takes the place of all three. It is a small wrapper
// with no dependencies, and under the hood it is the very same
// AsyncLocalStorage -- it just holds the store as a Map<string, unknown>, so
// you address values by key instead of through a typed Context object.

import express from 'express';
import httpContext from 'express-http-context';
import { randomUUID } from 'node:crypto';

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

// 1. Create the per-request store.
//    This one line replaces both `new AsyncLocalStorage()` and the
//    `asyncLocalStorage.run(...)` wrapper from the previous example.
//    Internally the middleware is literally:
//        asyncLocalStorage.run(new Map(), () => next())
//    Because next() is called inside run(), every middleware, route and
//    function further down the chain inherits the same store.
app.use(httpContext.middleware);

// 2. Put the request id into that store, once, at the boundary.
//    Previously this was the object we handed to run({ requestId: ... }).
//    Careful: set() silently does nothing when the middleware above is
//    missing, so there is no error to tell you the context was never opened.
app.use((req, res, next) => {
  httpContext.set('requestId', req.header('x-request-id') ?? randomUUID());
  next();
});

// Logger function.
// As we saw above, only this function reads requestId, and it pulls it from
// the store instead of receiving it as an argument. Every other signature
// below stays clean.
// The one change from the previous example: our custom getRequestId() helper
// is gone, and httpContext.get('requestId') now does that job for us.
async function logger(logData) {
  const requestId = httpContext.get('requestId');
  const logObject = {
    requestId,
    request: logData,
  };
  console.log(JSON.stringify(logObject));
}

// Function to make the HTTP request.
// Unchanged from the AsyncLocalStorage example: it never takes or mentions
// requestId, yet its log line still carries one.
async function apiCall(requestData) {
  // Stand-in for a real API call: wait instead of hitting the network.
  await new Promise((resolve) => setTimeout(resolve, 200));

  // Dummy API response
  const response = {
    success: true,
    data: {
      userId: 'U1',
      paymentId: 'U_P1',
      sentPayload: requestData,
    },
  };

  // Log. Nothing about the request id is passed in.
  await logger(response);
  return response;
}

// Function to save the data in the database.
// Also unchanged: a clean signature, and a correlated log line anyway.
async function saveData(apiResult) {
  // Stand-in for a real database write.
  await new Promise((resolve) => setTimeout(resolve, 100));

  // Log again, still without passing the request id anywhere.
  await logger({ saved: true, record: apiResult });
}

// This route takes the place of handleRequest() from the previous example.
// Notice there is no run() call to wrap it in: httpContext.middleware already
// opened the context for this request, so everything called from here can
// read the id.
app.post('/orders', async (req, res) => {
  // Example function to make an HTTP call
  const apiResult = await apiCall(req.body);

  // Example function to save the data in the database
  await saveData(apiResult);

  // Reading the id here too, to hand it back to the caller -- handy in a real
  // app, since the client can then quote it in a bug report.
  res.status(201).json({ success: true, requestId: httpContext.get('requestId') });
});

// Fire two requests at the same time and watch the ids stay separate:
//   curl -X POST localhost:3000/orders -H 'content-type: application/json' -d '{"userId":"u1"}'
app.listen(3000, () => {
  console.log('listening on http://localhost:3000');
});
Enter fullscreen mode Exit fullscreen mode

Run it with node, then fire two requests at once. The log lines interleave, but each keeps its own id:

{"requestId":"04757118-...","request":{"success":true,"data":{"sentPayload":{"userId":"u2",...}}}}
{"requestId":"cdce047b-...","request":{"success":true,"data":{"sentPayload":{"userId":"u1",...}}}}
{"requestId":"04757118-...","request":{"saved":true,"record":{...u2...}}}
{"requestId":"cdce047b-...","request":{"saved":true,"record":{...u1...}}}
Enter fullscreen mode Exit fullscreen mode

Two things to watch out for with this package specifically:

  • set() fails silently if you forget app.use(httpContext.middleware), or if you call it outside a request. Nothing is stored and no error is raised.
  • The store is a Map<string, unknown>, so httpContext.get('requestId') gives you any. In a TypeScript codebase the hand-rolled AsyncLocalStorage<Context> version keeps its types, which is a good reason to stay with the built-in.

A few things to keep in mind

AsyncLocalStorage is not magic, and the context does not follow your data everywhere:

  • It does not survive a queue. Work you push onto a job queue during a request and run later has no context. Capture it with AsyncLocalStorage.snapshot() at enqueue time.
  • EventEmitter listeners inherit the context of whoever calls .emit(), not the one they were registered in. Wrap them with AsyncResource.bind().
  • A shared or memoised promise belongs to whoever created it. If two requests await the same in-flight promise, its body runs in the first request's context — which can silently mix up more than just logs.
  • Prefer run() over enterWith(). enterWith() has no boundary, so it leaks back into the caller.
  • There is a small cost. Activating AsyncLocalStorage turns on promise hooks for the whole process, so every promise gets a little slower. For a request that already spends milliseconds in I/O that is noise; in a hot loop, read the store once at the top and pass the value down from there.

Wrapping up

If you are threading a request id, a tenant id, a trace id or a logged-in user through function signatures that don't care about any of it, AsyncLocalStorage removes that parameter entirely. Your service and repository functions go back to taking only the arguments they actually use, and your logs stay correlated anyway.

It is built into Node, needs no dependency, and in a real app the whole integration is usually one middleware.

I have put a full working example with Express in this repo — the same API written both ways, plus runnable demos for each of the gotchas above:

https://github.com/tarun-koshti/Node-Async-Local-Storage-Example

If you have used AsyncLocalStorage in production, I would love to hear where it helped you, or where it bit you. Drop a comment.

Thanks for reading!

Top comments (0)