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.

Comments

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

Leave a Reply