Correlation IDs
Attach one id to a request and carry it through all of its async work with AsyncLocalStorage, then pass it on.
asyncLocalStorage.run(store, callback[, ...args]) / asyncLocalStorage.getStore()One request to your service can become four more: a database query, a call to a payments service, a job pushed onto a queue. When something fails you want every log line those steps produced, and only those. A correlation id, also called a request id, is one value attached to all of them.
Create it at the edge. If the incoming request already carries an x-request-id header, reuse that value, because something in front of you has already written it down. Otherwise generate one with crypto.randomUUID(). Put it in every log line, and send it on in the same header on every outbound call.
Carrying it without passing it everywhere
Threading an id through every function signature is tedious and gets skipped. AsyncLocalStorage from node:async_hooks holds a value that stays reachable from all the asynchronous work started inside one call. It has been stable since Node 16.4. als.run(store, callback) runs the callback with the store set; als.getStore() reads it back from anywhere inside, including after an await. Two requests in flight at once each see their own store. They do not mix.
Where it escapes
A callback registered outside the run() call sees no store. Wrap such a function with AsyncLocalStorage.bind(fn), which captures the current context and became stable in Node 22.15 and 23.11.
store?.requestId is the same as store && store.requestId.
Example
When to use it
- Pull every log line for one customer's failed payment out of three services at once.
- Give a support agent one id to quote so an engineer can find the request.
- Attach the originating request's id to a background job so the job's logs link back.
- Log inside a deeply nested function without adding a parameter to every caller.
More examples
Assigning the id at the edge
The whole integration is one wrapper around the handler. Reuse of an incoming x-request-id matters once there is a proxy or gateway in front of the service: a new id there would split one request into two unlinked halves.
const http = require('node:http');
const { randomUUID } = require('node:crypto');
const { AsyncLocalStorage } = require('node:async_hooks');
const als = new AsyncLocalStorage();
http.createServer((req, res) => {
// Header names arrive lower-cased on req.headers.
const requestId = req.headers['x-request-id'] ?? randomUUID();
// Echo it back so a caller can quote it in a bug report.
res.setHeader('x-request-id', requestId);
// Everything the handler does runs inside this store.
als.run({ requestId }, () => {
handle(req, res);
});
}).listen(3000);A logger that finds the id by itself
This is the payoff: no function in the call chain takes a requestId parameter, and no log line is missing one. Reach for it when you notice the same id being threaded through functions that have no other use for it.
// context.js
const { AsyncLocalStorage } = require('node:async_hooks');
exports.als = new AsyncLocalStorage();
// log.js
const { als } = require('./context');
function log(level, msg, fields = {}) {
const store = als.getStore();
// Same as: store && store.requestId. Undefined on startup lines.
const requestId = store?.requestId;
console.log(JSON.stringify({ level, msg, requestId, ...fields }));
}
module.exports = { log };
// A function five calls deep needs no extra parameter.
async function chargeCard(order) {
log('info', 'charging', { orderId: order.id });
}Passing the id to the next service
An id that stops at your own service boundary only halves the problem. Sending it outward is what lets one search find the failure in the service downstream of you.
const { als } = require('./context');
async function fetchPaymentStatus(paymentId) {
const store = als.getStore();
const res = await fetch(`https://payments.internal/status/${paymentId}`, {
headers: {
// The receiving service reuses this instead of making its own id.
'x-request-id': store?.requestId ?? 'unknown',
},
});
if (!res.ok) throw new Error(`payments responded ${res.status}`);
return res.json();
}
Discussion