Node.js Async Hooks: The Tracing API That Powers Every APM Tool

Node.js Async Hooks: The Tracing API That Powers Every APM Tool
19 views

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. This guide focuses on Node.js async hooks,

⚡ 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 AsyncLocalStorage for 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 createHook only 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.

Related: Node.js Security Best Practices: Protect Your API From the Most Common Attacks · Day 32: Bedrock Converse API Tool Choice in Production · Day 11: Tool Use as an API, Not a Prompt Trick

Watch: 100 Days of AI on YouTube

Short videos from CheatCoders, one AI engineering topic per day.

Open the playlist on YouTube · CheatCoders on YouTube

Deep-dive PDF

Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.

Already subscribed? or open the subscribe page.


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.