NodeJSasync_hooks

async_hooks

The node:async_hooks module lets you track the lifecycle of asynchronous resources — promises, timers, callbacks, and anything else that crosses an async boundary. Every async resource gets a unique asyncId, and async_hooks fires callbacks when resources are created, when their callback runs, and when they're destroyed. This is the mechanism that powers AsyncLocalStorage (request-scoped context without passing objects through every function call), distributed tracing correlation IDs, and advanced debugging tools. Most application code uses AsyncLocalStorage directly and never touches the lower-level hooks API.

The async context problem

TS
// Problem: how do you pass a request ID through async call chains
// without threading it through every function signature?

app.get('/orders', async (req, res) => {
  const requestId = req.headers['x-request-id']

  // These functions are called across multiple async hops (await, setTimeout, etc.)
  // Without async context, they have no way to know the requestId:
  const orders = await fetchOrders()         // calls logger internally
  const enriched = await enrichOrders(orders) // calls logger internally
  res.json(enriched)
})

// logger.ts — needs the requestId but it's not passed in:
function log(message: string) {
  console.log(`[${???}] ${message}`)   // how to get the requestId here?
}
AsyncLocalStorage — the modern solution

TS
import { AsyncLocalStorage } from 'node:async_hooks'

// Create a store typed to your context shape:
const requestContext = new AsyncLocalStorage<{ requestId: string; userId?: string }>()

// Middleware: enter the store's scope for this request's entire async call chain:
app.use((req, res, next) => {
  const requestId = (req.headers['x-request-id'] as string) ?? randomUUID()
  requestContext.run({ requestId }, next)  // everything called from next() shares this context
})

// Any function anywhere in the call chain can read the context — no prop drilling:
function log(message: string) {
  const ctx = requestContext.getStore()
  console.log(JSON.stringify({ requestId: ctx?.requestId, message }))
}

// Works across any async boundary — setTimeout, Promises, setImmediate, I/O callbacks:
async function fetchOrders() {
  log('Fetching orders...')      // ✅ correctly gets this request's requestId
  await db.query('SELECT ...')
  log('Orders fetched')          // ✅ still has the requestId after the await
  return orders
}
AsyncLocalStorage.run() creates a context that automatically propagates through all async continuations — no need to pass context objects through function arguments
`AsyncLocalStorage` wraps the lower-level async_hooks API into a clean, high-level interface. When you call `.run(store, fn)`, everything called from `fn` — synchronously or asynchronously, across any number of `await` boundaries, callbacks, or timers — can call `.getStore()` and receive the same `store` object. This is possible because async_hooks tracks which async resource (Promise, timer, etc.) is the "parent" of each new async resource, and `AsyncLocalStorage` propagates the store through that parent chain. Each concurrent request gets its own independent store — there's no global shared state. This is how you implement correlation IDs, user context, tenant isolation, and transaction tracing without polluting every function signature.
AsyncLocalStorage with middleware and services

TS
// context.ts — define and export the store:
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'

export interface RequestCtx {
  requestId: string
  userId?: string
  startTime: number
}

export const ctx = new AsyncLocalStorage<RequestCtx>()

export function getCtx(): RequestCtx {
  const store = ctx.getStore()
  if (!store) throw new Error('Called outside of a request context')
  return store
}

// middleware.ts:
import { ctx } from './context'
export function requestContextMiddleware(req: Request, res: Response, next: NextFunction) {
  ctx.run({
    requestId: (req.headers['x-request-id'] as string) ?? randomUUID(),
    userId: (req as any).user?.id,
    startTime: Date.now(),
  }, next)
}

// user-service.ts — reads context without any parameter:
import { getCtx } from './context'
export async function getUser(id: string) {
  const { requestId } = getCtx()
  logger.info({ requestId, userId: id }, 'Fetching user')
  return db.users.findOne(id)
}
The low-level async_hooks API

TS
import { createHook, executionAsyncId, triggerAsyncId } from 'node:async_hooks'
import fs from 'node:fs'

// createHook registers callbacks for every async resource in the process:
const hook = createHook({
  init(asyncId, type, triggerAsyncId, resource) {
    // Called when an async resource is created (Promise, Timeout, TCPWRAP, etc.)
    fs.writeSync(1, `init ${type}(id:${asyncId}, trigger:${triggerAsyncId})\n`)
  },
  before(asyncId) {
    // Called just before the resource's callback executes
  },
  after(asyncId) {
    // Called just after the resource's callback executes
  },
  destroy(asyncId) {
    // Called when the resource is destroyed (GC'd)
  },
  promiseResolve(asyncId) {
    // Called when a Promise resolves
  },
})
hook.enable()

// Current execution context:
console.log(executionAsyncId())   // asyncId of the currently executing resource
console.log(triggerAsyncId())     // asyncId that triggered the current resource's creation
The low-level async_hooks API has significant performance overhead — enabling it in production slows all async operations; use AsyncLocalStorage which has much lower cost
The raw `createHook` API intercepts every single async resource creation and lifecycle event in the process. This adds measurable overhead — benchmarks show 10–30% throughput reduction when hooks are enabled globally. `AsyncLocalStorage` was specifically designed to have much lower overhead by only propagating context at creation time, not firing per-lifecycle callbacks. In production, use `AsyncLocalStorage` for context propagation (its overhead is minimal), and reserve raw `createHook` for development tooling, APM agents, or situations where you genuinely need lifecycle visibility. APM tools like Datadog and New Relic use this API internally — you shouldn't need to use it directly.
AsyncResource — manually creating async context

TS
import { AsyncResource } from 'node:async_hooks'

// Useful when integrating with event emitters or callback APIs that
// don't preserve async context automatically (EventEmitter, worker_threads message events):

class TrackedEmitter extends EventEmitter {
  emit(event: string, ...args: unknown[]) {
    // Wrap the emit in the context of where the listener was registered:
    const resource = new AsyncResource('TrackedEmitter')
    return resource.runInAsyncScope(() => super.emit(event, ...args))
  }
}

// Or bind a specific callback to a captured context:
const resource = new AsyncResource('DatabaseQuery')
const boundCallback = resource.bind((err: Error | null, result: unknown) => {
  // This callback runs in the async context captured when resource was created
  const store = requestContext.getStore()
  // ✅ store is available even though this callback is called from a different async context
})
AsyncResource.bind() captures the current async context and applies it when the callback fires — use it with legacy callback APIs and EventEmitters that don't propagate context
Not all APIs preserve async context automatically. The built-in `async_hooks` mechanism tracks context through Promises, `setTimeout`, `setImmediate`, and built-in stream callbacks — but third-party event emitters and manually-managed callback queues (like some message queue clients) may not. `AsyncResource.runInAsyncScope()` and `AsyncResource.bind()` let you explicitly run code in a previously-captured context. This is how APM agents patch third-party libraries to maintain tracing context, and how you can integrate `AsyncLocalStorage` with callback-based libraries that predate async_hooks.
Next
Measure precise timings inside Node with high-resolution APIs: [Performance Hooks (perf_hooks)](/nodejs/perf-hooks).