The fix for the singleton was not more careful code. It was a different place to keep the context: one that follows each request through every await and stays out of the other requests' way.
Part 1 ended on a bug that could not be fixed at the call sites. A per-request value (the log context) was stored in a per-process location (a singleton), so concurrent requests overwrote each other's context and the logs came out mislabeled. This post is the rewrite that fixed it, and the new problems the fix quietly introduced, because that is how these things go.
The primitive I did not trust in 2021
What the singleton lacked is a store scoped to a single request's asynchronous flow. You want to set a value at the start of a request and read it back forty frames and three awaits later, in the same request, while a dozen other requests do the same thing without ever seeing each other's values.
That is exactly what Node's AsyncLocalStorage provides. It is built on async_hooks, the low-level machinery that tracks the parent-child chain of every asynchronous resource: each promise, timer, and callback remembers which async context created it. AsyncLocalStorage rides that chain. When you call run(store, fn), it binds store to the current async context, and every async operation spawned inside fn, no matter how deeply nested or how many awaits later, inherits the same store. Two concurrent run() calls create two independent stores that never collide. It is, in effect, thread-local storage for a runtime that has no threads.
The reason I built a singleton in early 2021 instead of using this was covered in Part 1: back then AsyncLocalStorage was still experimental and the older CLS libraries had a reputation for losing context. By the second rewrite, AsyncLocalStorage was stable and boring, which for infrastructure is the highest compliment.
The rewrite: a scope per request
The second version, express-session-logger, is almost entirely this idea. One AsyncLocalStorage, an express middleware that opens a scope per request, and a logger that reads the scope.
const { AsyncLocalStorage } = require("async_hooks");
const context = new AsyncLocalStorage();
// middleware: build this request's context, then run the rest of the request inside it
function contextMiddleware(opt) {
return (req, res, next) => {
const child = logger.child({ requestId: opt.id(req) ?? uuid(), ...opt.context(req) });
const store = new Map([["logger", child]]);
return context.run(store, next); // <- next() and everything it awaits runs in this store
};
}
The context.run(store, next) line is the whole trick. Because next() is invoked inside run, the entire downstream middleware chain and route handler, and every await they perform, execute within this request's store. A parallel request gets its own run with its own store.
Reading the context back is a Proxy again, but this time it resolves per call from the async-local store instead of a singleton:
// the exported logger picks up the current request's child logger automatically
module.exports.logger = new Proxy(baseLogger, {
get(target, prop) {
const store = context.getStore();
const active = store ? store.get("logger") : target; // request's child, or base outside a request
return Reflect.get(active, prop);
},
});
Now a bare logger.info("payment failed") anywhere in the request comes out tagged with that request's requestId, and, crucially, a concurrent request logging at the same instant gets its own. The bug from Part 1 is gone by construction.
Proving the thing that mattered
The singleton version could not survive concurrency, so the rewrite's most important test is the one that interleaves requests on purpose and checks that nothing bleeds:
const results = await Promise.all(
Array.from({ length: 50 }, (_, i) =>
context.run(new Map([["id", i]]), async () => {
await delay(i % 7); // stagger so the scopes interleave
return context.getStore().get("id"); // must still be this task's own id
}),
),
);
results.forEach((id, i) => assert.equal(id, i)); // passes; the singleton could not
Fifty scopes, heavily interleaved, each reads back only its own value. This is the test the Part 1 design failed, and watching it go green is the moment the rewrite justified itself.
A bonus that fell out for free
There was an accidental gift in this design. The singleton version was welded to winston, because it knew how to attach a _context field to winston's records. The AsyncLocalStorage version stores a child logger and only ever calls .child() and the level methods on it. Any logger with a .child() interface fits: winston has it, pino has it, bunyan has it. Without meaning to, the rewrite became logger-agnostic. That accident becomes a headline feature in Part 3.
The new problems, honestly
The rewrite fixed the fatal flaw, and it was also a clear downgrade in three ways I did not appreciate until later. Every rewrite is like this: solving the big problem uncovers the small ones it was hiding.
It froze the context at request start. Look again at the middleware: logger.child({...}) is created once, at the top of the request, with the context known at that moment. But the most useful context often is not known yet. The user id arrives after auth. The tenant is resolved after a lookup. With a child created up front, anything you learn mid-request has nowhere to go: the child's metadata was fixed at birth, and there is no put() to enrich it. You end up with the request id and little else, which is a fraction of the MDC promise from Part 1.
It mutated shared state to accept a custom logger. To let callers pass their own logger, the middleware assigned it to a module-level variable on every request. Two parts of an app configuring two loggers would stomp on each other, a smaller cousin of the exact singleton disease the rewrite was supposed to cure.
It stayed a proof of concept. No TypeScript, no real test suite, an oddly specific winston-kafka-yan dependency left in from some forgotten integration, and express as the only supported framework. It worked, and it never grew up. Its version number, 0.0.5, was honest.
Where this points
So the second version got the hard thing right (isolation, via AsyncLocalStorage) and the ergonomics wrong (context frozen at request start, winston-shaped assumptions, WIP rough edges). The fatal architectural flaw was solved; what remained were design and polish problems, which are the good kind to have left.
The key one to fix is the frozen context. If childing the logger at request start is what loses mid-request data, then the answer is to stop childing early and instead attach the context at the moment each line is written, using a hook the logger already gives you for exactly this. Doing that cleanly, for winston and pino alike, while bringing back the full put/get MDC and adding the types and tests, is the third version, and it is the one on npm today. That is Part 3.