Every time you wonder “how does Datadog know which database query belongs to which HTTP request?”, the answer is async_hooks. It’s one of the most powerful and least understood Node.js APIs. It tracks the causal chain of async operations — when async resource A creates async resource B, async_hooks knows. This makes automatic request tracing, correlation IDs, and context propagation possible without any changes to application code.
⚡ TL;DR:
async_hooks.createHook()fires callbacks when async resources are created, destroyed, or when execution enters/leaves them.AsyncLocalStorage(built on async_hooks) is the production-ready way to propagate request context across all async operations without passing it as a parameter.
The Problem: Context Gets Lost Across Async Boundaries
// Without async_hooks: context must be threaded through every call
app.get('/users', async (req, res) => {
const requestId = req.headers['x-request-id'];
const users = await getUsers(requestId); // Must pass requestId
const enriched = await enrich(users, requestId); // Must pass requestId
res.json(enriched);
});
async function getUsers(requestId) { // Must accept and thread requestId
logger.info('Fetching users', { requestId }); // Tedious
return db.query('SELECT * FROM users');
}
// With AsyncLocalStorage: context available anywhere, automatically
const store = new AsyncLocalStorage();
app.get('/users', async (req, res) => {
await store.run({ requestId: req.headers['x-request-id'] }, async () => {
const users = await getUsers(); // No context passing!
res.json(users);
});
});
async function getUsers() {
const { requestId } = store.getStore(); // Context available here
logger.info('Fetching users', { requestId }); // Zero plumbing
return db.query('SELECT * FROM users');
}
AsyncLocalStorage: Production Request Tracing
const { AsyncLocalStorage } = require('async_hooks');
const { randomUUID } = require('crypto');
const requestContext = new AsyncLocalStorage();
// Middleware: attach context to every request
app.use((req, res, next) => {
const ctx = {
requestId: req.headers['x-request-id'] || randomUUID(),
userId: req.user?.id,
startTime: Date.now(),
spans: [],
};
res.setHeader('x-request-id', ctx.requestId);
requestContext.run(ctx, next); // Everything in this request gets ctx
});
// Logger that automatically includes request context
const logger = {
info: (msg, extra = {}) => {
const ctx = requestContext.getStore() || {};
console.log(JSON.stringify({
level: 'info', msg, requestId: ctx.requestId,
userId: ctx.userId, ...extra
}));
}
};
// Database wrapper that auto-traces queries
async function query(sql, params) {
const ctx = requestContext.getStore();
const start = Date.now();
try {
const result = await db.query(sql, params);
ctx?.spans.push({ type: 'db', sql, duration: Date.now() - start });
return result;
} catch (err) {
logger.info('Query failed', { sql, error: err.message });
throw err;
}
}
// Now every function in the request automatically logs with requestId
app.get('/users/:id', async (req, res) => {
logger.info('Fetching user'); // requestId auto-included
const user = await query( // span auto-recorded
'SELECT * FROM users WHERE id = $1', [req.params.id]
);
logger.info('User fetched', { userId: user.id }); // requestId auto-included
res.json(user);
});
Performance Monitoring with async_hooks
const { createHook, executionAsyncId } = require('async_hooks');
// Track active async resources — spot resource leaks
const activeResources = new Map();
const hook = createHook({
init(asyncId, type, triggerAsyncId) {
activeResources.set(asyncId, { type, triggerAsyncId, created: Date.now() });
},
destroy(asyncId) {
activeResources.delete(asyncId);
},
promiseResolve(asyncId) {
activeResources.delete(asyncId);
}
});
hook.enable();
// Check for resource leaks periodically
setInterval(() => {
const now = Date.now();
const old = [...activeResources.entries()]
.filter(([, r]) => now - r.created > 10000) // Older than 10s
.map(([id, r]) => `${r.type}(${id})`);
if (old.length > 100) {
console.warn('Possible resource leak:', old.length, 'old resources');
}
}, 5000);
Async Hooks Cheat Sheet
- ✅ Use
AsyncLocalStoragefor request context — it’s the stable, production-ready API - ✅
store.run(ctx, fn)to attach context to a scope - ✅
store.getStore()to retrieve context anywhere in the async chain - ✅ Use
createHookonly for low-level instrumentation (APM tools) - ✅ AsyncLocalStorage survives: await, setTimeout, setInterval, event emitters
- ❌ Don’t use async_hooks for hot paths — it adds ~10-20% overhead per async operation
- ❌ Don’t store large objects in AsyncLocalStorage — context is held for the lifetime of all child async ops
Async hooks are most powerful in the context of the Node.js event loop internals guide — understanding how async resources are scheduled makes async_hooks callbacks predictable. For the streaming use case, Node.js streams create async resources that async_hooks tracks, which is how APM tools measure stream processing time. External reference: Node.js AsyncLocalStorage documentation.
Master Node.js internals and performance monitoring
→ View Course on Udemy — Hands-on video course covering every concept in this post and more.
Sponsored link. We may earn a commission at no extra cost to you.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
