Node.js AsyncLocalStorage - Stop Passing requestId Through Every Function
Problem
When implementing per-request logging in Express, you end up passing requestId through every function — service layer, DB layer, utilities, everything.
app.get('/users/:id', (req, res) => {
const requestId = crypto.randomUUID();
const user = getUser(req.params.id, requestId);
res.json(user);
});
function getUser(id, requestId) {
logger.info(`[${requestId}] getUser called`);
return queryDB(`SELECT * FROM users WHERE id = $1`, [id], requestId);
}
function queryDB(sql, params, requestId) {
logger.info(`[${requestId}] query: ${sql}`);
// ...
}
Every function signature is polluted with a parameter that has nothing to do with business logic.
Solution
AsyncLocalStorage propagates context implicitly through async call chains. It’s built into Node.js — no packages needed.
const { AsyncLocalStorage } = require('node:async_hooks');
const crypto = require('node:crypto');
const asyncLocalStorage = new AsyncLocalStorage();
// Express middleware
function requestContext(req, res, next) {
const store = {
requestId: crypto.randomUUID(),
method: req.method,
path: req.path,
};
asyncLocalStorage.run(store, next);
}
app.use(requestContext);
Now access the context anywhere with getStore().
// Logger utility
function createLogger(module) {
return {
info(msg) {
const store = asyncLocalStorage.getStore();
const requestId = store?.requestId ?? 'no-context';
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
requestId,
module,
msg,
}));
}
};
}
// Service layer - no more requestId parameter
const logger = createLogger('user-service');
function getUser(id) {
logger.info(`getUser called: ${id}`);
return queryDB(`SELECT * FROM users WHERE id = $1`, [id]);
}
function queryDB(sql, params) {
logger.info(`query: ${sql}`);
// ...
}
Logs from the same request share the same requestId, making debugging a single grep away.
Key Points
AsyncLocalStorageis stable since Node.js 16.4 — zero dependencies required- All async code inside
run()shares the same store:setTimeout,Promise,async/awaitall work - Performance overhead is negligible. Node.js 23+ uses V8-native
AsyncContextFramefor even better performance - NestJS offers
ClsModule, Fastify has@fastify/request-context— same pattern, different wrappers - Beyond logging: use it for auth context, transaction IDs, or multi-tenant identification