Request context with AsyncLocalStorage

A stack trace says where an error was thrown. AsyncLocalStorage is how you also know which request was being served — without threading a context object through every function.

The problem it solves

Node.js handles requests concurrently on one thread, so there is no thread-local storage to hang per-request state on. The explicit answer is to pass a context object down every call, which works and is tedious — and breaks the moment an error is thrown four layers deep in code that never accepted the parameter.

`AsyncLocalStorage` maintains a store that follows the asynchronous execution context. Code inside `run()` can read the store at any depth, across `await`, `setTimeout` and callbacks, with no parameter passing.

context.js
import { AsyncLocalStorage } from 'node:async_hooks';

const requestContext = new AsyncLocalStorage();

export function contextMiddleware(req, res, next) {
  requestContext.run({ requestId: crypto.randomUUID(), route: req.path }, next);
}

// Readable anywhere downstream — no parameter threading.
export function currentContext() {
  return requestContext.getStore();
}

Three ways to lose the context silently

First: calling `next()` outside `run()`. The store must wrap the continuation, so `run(store, next)` works and `run(store, () => {}); next();` does not.

Second: queues and connection pools that were created before the store existed. A pooled connection or an `EventEmitter` listener registered at module load captured its own async context at creation time, and callbacks fired from it will not see the request store.

Third: anything that crosses a process boundary. A BullMQ job, a worker thread or an HTTP call to another service starts a fresh context. Context that must survive a hop has to be serialised into the payload or into headers — a trace ID is the usual carrier.

What it costs

AsyncLocalStorage is not free: it requires async context tracking in the runtime, and it has historically been one of the more expensive async_hooks features. Performance has improved substantially in recent Node.js versions, and for a typical HTTP service the cost sits far below the I/O it is tracking.

The practical guidance is to use one store per concern rather than many, keep the stored object small, and measure on your own workload rather than trusting a number from a blog post — including this one. If you need a figure, benchmark your service with the middleware on and off.

How this shows up in error reports

The payoff is that a captured exception can carry the route, method, user and request id without the throwing code knowing anything about reporting. The SDK reads the active store at capture time.

VantaTrace's `requestHandler()` establishes this store, which is why `captureException` called deep inside a service layer still produces a report attached to the right request. Note the deliberate boundary: the store holds a reference to the request for enrichment but the reference is removed before serialisation, so the whole request object is never shipped.

service.js
// No context parameter, no reporting import — the SDK reads the active store.
export async function chargeCustomer(customerId, amountCents) {
  try {
    return await paymentGateway.charge(customerId, amountCents);
  } catch (error) {
    // Reported WITH the route, method and user of the active request.
    vantaTrace.captureException(error, {
      metadata: { customerId, amountCents },
    });
    throw error;
  }
}

Related guides