To better understand the challenge, let’s refactor our code to use a global object as a context// store.tsconst store = new Map();// server.tsimport { store } from “store”router.get(path.join(‘/’, ‘user-details’), async (req, res) => {const userDetailsRequest = extractRequestParams(req);store.set(‘xRequestId’, generateXRequestId());const userDetailsResponse = await getUserDetails(userDetailsRequest);res.send(userDetailsResponse);},);// getUserDetails.tsimport { logger } from “logger”;export async function getUserDetails(userDetailsRequest: UserDetailsRequest) {const isPermitted = await checkUesrReqPermissions(userDetailsRequest);if (!isPermitted) {throw new NotPermittedError(‘Not permitted’);}const userDetalis = await queryUserDetails(userDetailsRequest);await logger.report(`getUserDetails response ${userDetalis} for request ${userDetailsRequest}`);return userDetails;}// logger.tsimport { store } from “./store”;export const logger = {report(msg: string) {const xRequestId = store.get(“xRequestId”);return remoteLogger.log(`${xRequestId ?? ‘-‘}: ${msg}`);}}The problem with this code happens when request #2 reaches the backend. It will override the value of xRequestId with a new one and when the time comes for request #1 to complete its last backend API call, it will be logged with the xRequestId of request #2.So global object as a context is not an option, we need a way to associate a context within an async flow similar to TLS but this time on the same thread.Multithreading“If you can’t solve a problem, then there is an easier problem you can solve: find it.” – George PolyaWe have a solution for a multi-threaded environment and it is TLS, so maybe we should solve a different problem — making Node a multi-threaded environment, and then we could use TLS as wellDespite its single-threaded nature, Node provides a way to create threads like worker threads, child processes, and clusters. How would that work if we used a multi-threaded approach in Node? Say, for example, every request and each async operation will be handled by a different worker thread. Since Node threads do not share memory by default, we can use a global object in this case as our TLS solution implementation.“Unlike child_process or cluster, worker_threads can share memory. They do so by transferring ArrayBuffer instances or sharing SharedArrayBuffer instances.”This means that child_processes and clusters don’t share memory anyway, and the same goes for worker threads unless you explicitly use ArrayBuffers or SharedArrayBuffers.Although it sounds like we have found a solution in theory, it is not practical. Unlike traditional multi-threaded environments, Node uses a single thread for I/O operations to reduce context switching and this is why it performs better when it comes to I/O. Worker threads were designed to handle CPU-intensive tasks, not I/O tasks, so if you need multiple threads to handle I/O, you probably shouldn’t use Node.“Workers (threads) are useful for performing CPU-intensive JavaScript operations. They do not help much with I/O-intensive work. The Node.js built-in asynchronous I/O operations are more efficient than Workers can be.”AsyncLocalStorage is a built-in Node.js API that provides a way of propagating the context of the current async operation through the call chain without the need to explicitly pass it as a function parameter. It is similar to thread-local storage in other languages.The main idea of Async Local Storage is that we can wrap some function calls with the AsyncLocalStorage#run call. All code that is invoked within the wrapped call gets access to the same store, which will be unique to each call chain.Let’s refactor our code to use the AsyncLocalStorage API// store.tsimport { AsyncLocalStorage } from ‘node:async_hooks’;export const asyncLocalStorage = new AsyncLocalStorage();// server.tsimport { asyncLocalStorage } from “./store”;router.get(path.join(‘/’, ‘user-details’), async (req, res) => {const store = new Map();store.set(“xRequestId”, generateXRequestId());asyncLocalStorage.run(store, async () => {const userDetailsRequest = extractRequestParams(req);const userDetailsResponse = await getUserDetails(userDetailsRequest);res.send(userDetailsResponse);});});// getUserDetails.tsimport { logger } from “logger”;export async function getUserDetails(userDetailsRequest: UserDetailsRequest) {const isPermitted = await checkUesrReqPermissions(userDetailsRequest);if (!isPermitted) {throw new NotPermittedError(‘Not permitted’);}const userDetalis = await queryUserDetails(userDetailsRequest);await logger.report(`getUserDetails response ${userDetalis} for request ${userDetailsRequest}`);return userDetails;}// logger.tsimport { asyncLocalStorage } from “./store”;export const logger = {report(msg: string) {const store = asyncLocalStorage.getStore();const xRequestId = store.get(“xRequestId”);return remoteLogger.log(`${xRequestId ?? ‘-‘}: ${msg}`);}}Now, the logger has access to the xRequestId value and the AsyncLocalStorage handles the isolation for me.While you can create your own implementation on top of the node:async_hooks module, AsyncLocalStorage should be preferred as it is a performant and memory safe implementation that involves significant optimizations that are non-obvious to implement.async_hooks Performance AnalysisDisclaimer: The performance test was done on a Mac-M1 machine with Node version 16.14.0To test the performance impact of AsyncLocalStorage I’ve created 4 functions to simulate different use cases.1. Regular (no hooks)function simpleAsyncOperation() {return new Promise((resolve) => {setTimeout(() => {resolve(“done”);});});}measure(simpleAsyncOperation);2. With AsyncLocalStorageimport {AsyncLocalStorage} from “node:async_hooks”;export const asyncLocalStorage = new AsyncLocalStorage();function withAsyncLocalStorage() {return asyncLocalStorage.run({}, () => {return simpleAsyncOperation();});}measure(withAsyncLocalStorage);3. With a single async hookimport async_hooks from “node:async_hooks”;function setupWithAsyncHook() {return async_hooks.createHook({init(asyncId, type, triggerAsyncId, resource) { },before(asyncId) { },after(asyncId) { },destroy(asyncId) { },promiseResolve(asyncId) { },}).enable();}setupWithAsyncHook();measure(simpleAsyncOperation);Note: A custom implementation of AsyncLocalStorage can be achieved using a single async hook4. With an async hook for every async operationfunction withAsyncHooks() {setupWithAsyncHook();return simpleAsyncOperation();}measure(withAsyncHooks);You can see that using async operations and AsyncLocalStorage impacts performance and it is written pretty clearly on the async_hooks docs -Please migrate away from this API, if you can. We do not recommend using the createHook, AsyncHook, and executionAsyncResource APIs as they have usability issues, safety risks, and performance implicationsI didn’t count the last use case async hook per async operation in the chart, because I couldn’t really think of a real use case for it, async hook on demand did not really make sense and also the numbers were so ridiculous to compare.The numbers were -1,000 requests – 180ms10,000 requests – 14,000ms100,000 requests – I stopped the program after 2 minHere is the snippet code of the measure functionimport { PerformanceObserver, performance } from “node:perf_hooks”;export async function measure(callback: () => Promise, label = “hooks-performance-test”) {const perfObserver = new PerformanceObserver((items) => {items.getEntries().forEach((entry) => {console.log(entry)})});perfObserver.observe({ entryTypes: [“measure”], buffered: true });performance.mark(“performance-test-start”);await callback();performance.mark(“performance-test-end”);performance.measure(label, “performance-test-start”, “performance-test-end”);}Performance SummaryAsync hooks and AsyncLocalStorage have a significant impact on the application performanceIt is better to use the build-in AsyncLocalStorage than a custom oneas said in the docsContext LossIn most cases, AsyncLocalStorage works without issues. In rare situations, the current store is lost in one of the asynchronous operations.If your code is promise-based there should be no problem otherwise if it is callback-based, it is enough to promisify it with util.promisify() so it starts working with native promises.ConclusionAsyncLocalStorage is the best option for a TLS-like solution to store a local context between an async flow.Before using it you should be aware of its pitfallspossible context loss if you have a callback-based APIperformance impactAdditional resources and further reading: