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