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.
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.
// 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;
}
}