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

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

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 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.

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.