Node.js Internals
Async Hooks: Tracking Asynchronousness
Imagine: you have 1000 simultaneous HTTP requests. Each request performs 5-10 async operations (DB, Redis, HTTP calls). How do you track which log belongs to which request? How do you measure where a specific request is slowing down? Common approaches (passing requestId through all functions) turn the code into a nightmare. Async Hooks solves this problem **elegantly**.
- **APM systems** (DataDog, New Relic, Sentry) use async_hooks for automatic tracing without changing your code.
- **NestJS** uses AsyncLocalStorage for Dependency Injection and REQUEST scope providers
- **Prisma** uses async_hooks for automatic management of database transactions in async code.
- **Distributed tracing** (Jaeger, Zipkin) in microservices - without async_hooks it is impossible to link requests between services
Why track asynchronicity
In Node.js, each asynchronous operation creates an **invisible call chain**. When a request goes through `async` functions, promises, timers, and callbacks - the context is lost. How can you understand which logger belongs to which request? How can you measure the execution time of an asynchronous chain? How can you track memory leaks in promises?
**Async Hooks** is a low-level API that gives you an X-ray of asynchronicity. It shows the birth and death of every promise, every timer, every TCP connection. It is a tool for profilers, APM systems, and request tracing.
**Key Idea:** Each asynchronous operation in Node.js is a **resource** with a unique ID. Async Hooks allows you to subscribe to the lifecycle events of these resources.
Each `asyncId` is unique, `triggerAsyncId` shows the parent. This is how Node.js constructs the **asynchronous call tree**.
What happens when a promise is created inside setTimeout?
Lifecycle of an Asynchronous Resource
Each asynchronous resource goes through four stages: **init → before → after → destroy**. This is similar to a processor pipeline, but for asynchronous operations.
**Important:** `before` and `after` can be called **multiple times** for a single resource (for example, a stream or a socket), but `init` and `destroy` - **exactly once**.
**Performance warning:** Async Hooks have an overhead of ~10-30%. Use only for debugging and profiling, not in production unless necessary.
Why can before/after be called multiple times for a single asyncId?
createHook API and executionAsyncId
The API `async_hooks.createHook()` takes an object with callbacks. Besides `init/before/after/destroy`, there are two important helper methods: `executionAsyncId()` and `triggerAsyncId()`.
**Pattern:** Use `executionAsyncId()` as a key to store the request context. This allows linking logs, metrics, and traces with a specific HTTP request.
Example: request tracing for an HTTP server. Each request receives a unique ID, which is propagated through the entire async chain.
**The problem with this approach:** You need to manually clean the Map, otherwise there will be a memory leak. For production, it is better to use `AsyncLocalStorage` (the next concept).
What will executionAsyncId() return inside a promise created in setTimeout?
AsyncLocalStorage: Context without pain
`AsyncLocalStorage` is a **high-level wrapper** over async_hooks that solves the problem of passing context through async chains. It's like thread-local storage, but for asynchronous code.
**Key difference:** With AsyncLocalStorage, you don't need to manually manage the Map and clean up memory. Node.js does this automatically.
**Magic:** Context is automatically propagated through `await`, `.then()`, `setTimeout()`, `setImmediate()`, EventEmitter, and even through worker threads (with limitations).
**Production pattern:** Use AsyncLocalStorage for request tracing, logging, metrics, and feature flags. This is the de facto standard in modern Node.js.
What is the main advantage of AsyncLocalStorage over manual Map + async_hooks?
Debugging asynchronous threads
Async Hooks - a powerful tool for **debugging complex async bugs**: memory leaks, race conditions, lost promises. You can build a map of all active resources and find what is not being cleaned up.
**Pattern:** If you see a leak of type `PROMISE`, look for promises without `.catch()` or forgotten listeners on EventEmitter.
Another use case is **visualization of async flow**. You can build a dependency graph between resources and understand why a request is hanging.
**Overhead:** Tracking all resources can create hundreds of thousands of entries in the graph. For production, use sampling (track only 1% of requests).
How to Find a Memory Leak Using async_hooks?
Production Patterns
In production, async_hooks is used for **APM systems** (Application Performance Monitoring), **distributed tracing**, and **context propagation**. Here are the proven patterns.
Pattern 1: Request Context Middleware
Pattern 2: Distributed Tracing (OpenTelemetry style)
Pattern 3: Feature Flags with context
**Real-world examples:** AsyncLocalStorage is used in **NestJS** (for DI context), **Prisma** (for transaction management), **Sentry** (for error context), **Pino** (for structured logging).
**Performance tip:** Use `als.enterWith()` instead of `als.run()` if you need to set the context without creating a new scope. It is slightly faster.
AsyncLocalStorage works like thread-local storage in Java - each thread has its own copy of the variable.
AsyncLocalStorage is bound to the **async execution context**, not to physical threads. A single Node.js process (one thread) can have hundreds of isolated async contexts.
Node.js is single-threaded (event loop), but async_hooks creates **virtual contexts** for each async chain. This is closer to 'continuation-local storage' than to thread-local.
What is AsyncLocalStorage used for in production applications?
Key Ideas
- **Async Hooks** provides access to the lifecycle of asynchronous resources (promises, timers, sockets) through the `init/before/after/destroy` callbacks.
- **executionAsyncId()** returns the ID of the current async context, **triggerAsyncId()** - the ID of the parent. This is how the async call tree is constructed.
- **AsyncLocalStorage** - a high-level wrapper over async_hooks for propagating context without explicit parameter passing.
- **Use cases:** request tracing, structured logging, distributed tracing, APM, debugging memory leaks, feature flags with context
- **Trade-off:** Async Hooks adds 10-30% overhead. Use sampling in production or only for critical operations.
Related topics
Async Hooks are closely related to other aspects of Node.js:
- Event Loop — Async Hooks track the phases of the event loop: timers, I/O, setImmediate. Understanding the event loop is necessary to understand when before/after are called.
- Promises & Async/Await — Each promise creates an async resource. Understanding promises helps to understand why before/after are called for .then()
- Streams — Streams are long-lived resources that call before/after multiple times for each chunk.
- Cluster & Worker Threads — AsyncLocalStorage does NOT work between worker threads by default - you need to explicitly pass the context.
Вопросы для размышления
- How would you implement request tracing in a microservices architecture using AsyncLocalStorage?
- In what cases can async_hooks lead to memory leaks? How to avoid this?
- Why doesn't AsyncLocalStorage work between worker threads? How can this problem be solved?
- Imagine you need to measure the execution time of each function in an async chain. How can you use async_hooks for this?