The power of Async Hooks in Node.js

Sebastian Curland
Nielsen-TLV-Tech-Blog
7 min readMar 28, 2020

--

Theasync_hooks module provides an API to track asynchronous resources in Node.js. An async resource is an object with a callback function associated with it. Because of Node’s nature, almost all things happening under the hood in Node.js are asynchronous calls, and a lot of these async resources are created.

Examples of async resources are Promises, Timeouts, Immediates (when calling setImmediate), TickObject (when calling process.nextTick), TCPWRAP (when creating a server).
You can find the full list of async resources in the Async Hooks docs.

Before deep-diving into useful examples, let’s do a quick overview of the Async Hooks API.

API basics

The API provides the following events to track the life-cycle of async resources:

  • init — called when an async resource is initialized.
  • before/after — called just before/after the callback associated with the async resource is executed.
  • destroy — called after the resource is destroyed.
  • promiseResolve — called when the resolve function of the Promise constructor is invoked.

When creating an AsyncHooks instance, you need to specify which of the above events you want to be triggered, and for each of those events, a callback function should be provided.

// Only init and destroy are used in this exampleconst async_hooks = require('async_hooks')
const asyncHook = async_hooks.createHook({ init, destroy })
asyncHook.enable()
function init(asyncId, type, triggerAsyncId, resource) { // code }
function destroy(asyncId) { // code }

The init callback gets these parameters:

  • asyncId — unique ID of the async resource.
  • type — a string representing the type of the async resource (e.g., Promise, Timeout, Immediate, TCPWRAP, etc.).
  • triggerAsyncId — the unique ID of the async resource in whose execution context this async resource was created.
  • resource — an object that represents the actual async resource and contains information about it.

The before, after, destroy, and PromiseResolve callbacks only get the asyncId of the resource.

Using console.log() in AsyncHooks callbacks

Hopefully, this module caught your attention by now, and all you want is to print to the console all the async resources in your application. You’ll probably end up writing something like this:

const async_hooks = require('async_hooks')
const asyncHook = async_hooks.createHook({ init })
asyncHook.enable()
function init(asyncId, type, triggerAsyncId, resource) {
console.log(asyncId, type)
}

Running the above code will output:

RangeError: Maximum call stack size exceeded

The reason is that console.log() is an asynchronous operation in Node.js. That means that when the init callback runs, console.log() will trigger a new init event, producing an endless recursion, and in the end, reaching the maximum call stack size.

To work around this problem, you can use a synchronous print function instead of console.log(). For example fs.writeFileSync will print to the console and will not invoke AsyncHooks recursively because it is synchronous.

function debug(...args) {
fs.writeFileSync(1, `${util.format(...args)}\n`, { flag: 'a' });
}
function init(asyncId, type, triggerAsyncId, resource) {
debug(asyncId, type)
}

Ok, this should cover the basics of the API. If you want to get more details on Async Hooks API, just check Node’s docs.
Let’s see now some examples and use-cases on how and when to use async hooks.

Async / await

It is a common misbelief that async/await is just syntactic sugar on top of Promises. While it is true that async/await is implemented with Promises under the hood, there are some differences to using explicit promises in your code. With async hooks, we can check the Promise resources being created when using async/await:

// init async hook
function init(asyncId, type, triggerAsyncId, resource) {
debug(asyncId)
}
// two functions with async/await
const computeAnswer = async () => { 123 }
(async () => {
await computeAnswer()
})()
// Output:
// when running on Node < v12.0.0
5 'PROMISE'
6 'PROMISE'
7 'PROMISE'
8 'PROMISE'
9 'PROMISE'
// when running on Node >= v12.0.0
2 PROMISE 1
3 PROMISE 1
4 PROMISE 3

The computeAnswer and the immediately-invoked functions return a Promise, so two Promise resources are created for them. If the above program is executed in a Node version smaller than v12.0.0, then the await creates three additional Promises.
When running the code in Node 12, we can see that only three Promise resources are created in total, and this is because of the Async performance improvements in V8 that were included in Node 12.

Profiling — Measuring the duration of async operations

When we combine the Async Hooks module with the Performance API — another core module in Node, we can do more exciting things like measuring the duration of asynchronous operations.

Let’s explore a simple example that illustrates this — the below example measures the real-time that takes to execute a timeout operation, including the time that takes to execute its callback function.

const async_hooks = require('async_hooks')
const {performance, PerformanceObserver} = require('perf_hooks')
const set = new Set()
const hook = async_hooks.createHook({
init(id, type) {
if (type === 'Timeout') {
performance.mark(`Timeout-${id}-Init`)
set.add(id)
}
},
destroy(id) {
if (set.has(id)) {
set.delete(id);
performance.mark(`Timeout-${id}-Destroy`)
performance.measure(`Timeout-${id}`,
`Timeout-${id}-Init`,
`Timeout-${id}-Destroy`)
}
}
})
hook.enable()
const obs = new PerformanceObserver((list, observer) => {
console.log(list.getEntries()[0])
performance.clearMarks()
observer.disconnect()
})
obs.observe({ entryTypes: ['measure'], buffered: true })
setTimeout(() => {}, 1000)

And the output is:

PerformanceEntry {
name: 'Timeout-3',
entryType: 'measure',
startTime: 40.573846,
duration: 1004.292745
}

In the init hook, a PerformanceMark is created if the type of the async resource created is Timeout, and the id of that timeout resource is stored in a Set.
When the timeout resource is destroyed (meaning that its callback is completed), we add another PerformanceMark for the destroy event. We measure, using the Performance module, the time of the two marks — when the timeout resource was created (startTime) and when it was destroyed (duration).

In the profiling-land, clinic.js has a tool called Bubbleprof that helps users to determine where asynchronous time is spent in their application. Bubbleprof tries to collect and aggregate all async operations — using Async Hooks, and then group them into visual bubbles based on this analysis.

Execution Context

Another popular use-case for Async Hooks is storing context data like it’s done with thread-local storage in other programming languages. Suppose you have a web application, and you want to access data that is included in an HTTP request, like a user id in some header. You want to get this id in the different layers of your application, like in the business logic layer and the data-access layer. A straightforward solution would be to pass the request object to every function on every layer so you will be able to get the user id. But this solution is against Clean Code principles — why your business logic or the data-access code should “know” about HTTP request objects?

The solution would be to store the request data (the context) and provide a getter to get the context at any time, everywhere in the code. Also, it would be nice to remove the request context data once the request is fulfilled. This can be achieved easily with Async Hooks:

asyncHooks.createHook({ init, destroy }).enable()
const reqContextMap = new Map()
function createRequestContext (data) {
reqContextMap.set(asyncHooks.executionAsyncId(), data)
}
function getRequestContext () {
return reqContextMap.get(asyncHooks.executionAsyncId())
}
function init (asyncId, type, triggerAsyncId, resource) {
// Store same context data for child async resources
if (reqContextMap.has(triggerAsyncId)) {
reqContextMap.set(asyncId, reqContextMap.get(triggerAsyncId))
}
}
function destroy (asyncId) {
if (reqContextMap.has(asyncId)) {
reqContextMap.delete(asyncId)
}
}

The way this work is when the server gets a new request the createRequestContext function is called with the data we want to store for the request. The data is stored in a Map with the executionAsyncId as the key. Every async operation, like Promises, that are created as part of the current request, will be added to the Map with the same context data that was saved initially for the request (this is what the init hook does).
The destroy hook will do the cleanup, making sure the Map size does not endlessly grow.
Finally, everywhere in the code, you can call getRequestContext to get the data of the current execution context, without knowing nothing about HTTP requests.

Luckily for us, Node has this functionality built-in in the Async Hooks module, with the AsyncLocalStorage class that was added in Node v13.10.0.
This class provides a high-level API on top of async hooks. Since it’s part of Node core, it’s supposed to be more stable and to have better performance than other custom solutions like the code example above or other popular packages like CLS — Continuation Local Storage (of course, once the async hooks API becomes officially stable and not experimental like it is today, as of April 2020).

Enhanced Stack Traces

Back in 2011, Ryan Dahl — the man behind Node.Js, gave an introductory talk about Node.JS, and when talking about debuggability problems he said:

there’s this problem with event loops which is that you’re always killing your stack, your stacks are very short

What Ryan Dahl is pointing out is the fact that, in Javascript, when executing the callbacks of asynchronous operations, the call stack is empty.

With Async Hooks we have a way to capture the stack traces of async operations and, similar to what we did with execution context, we can save the stack traces and get them whenever there is an error:

const asyncHooks = require('async_hooks')
const stackMap = new Map()
asyncHooks.createHook({ init }).enable()function init(asyncId, type, triggerAsyncId) {
const parentStack = stackMap.get(triggerAsyncId) || ''
let currentStack = {}
Error.captureStackTrace(currentStack)
stackMap.set(asyncId, currentStack.stack + parentStack)
}
const getError = function (...args) {
const err = new Error(...args)
err.stack += stackMap.get(asyncHooks.executionAsyncId())
return err
}

Eight years after Ryan Dahl’s talk, V8 added Async Stack Traces to the engine (release v7.3) which was included in Node v12.
V8’s Async stack traces provide similar functionality to the above code (and probably works much faster), with the only drawback that they don’t work with Promise.then() and Promise.catch() .

Worker Threads

If you are already using or planning to use Node’s worker threads and combine them with Async Hooks API, you need to keep in mind that each thread will have an independent async_hooks interface, and each thread will use a new set of async IDs.

In this case, what will come in handy is the AsyncResource class from Async Hooks module that allows us to create custom async resources that will benefit from the Async Hooks API.

There is an excellent example in Async Hooks documentation that demonstrates how to use the AsyncResource class to provide async tracking for a Worker pool.

Thanks for reading! I hope you enjoyed this article. Let me know if you have any comments or feedback.

Stay tuned for more Node.js articles and follow me on Twitter https://twitter.com/sebcurland

--

--

Sebastian Curland
Nielsen-TLV-Tech-Blog

Senior full-stack developer and security champion at the Nielsen Marketing Cloud.