Node.js’s AsyncLocalStorage lets you carry context across asynchronous operations without resorting to passing arguments everywhere or using global variables.
Let’s see it in action. Imagine you’re logging requests in a web server. Each request has a unique ID you want to attach to every log message generated during its processing, no matter how deep the async call stack goes.
import { AsyncLocalStorage } from 'async_hooks';
const requestIdStorage = new AsyncLocalStorage();
async function processRequest(id, callback) {
await requestIdStorage.run(id, async () => {
console.log(`[${requestIdStorage.getStore()}] Starting request processing.`);
await simulateWork(100);
await handleDatabaseQuery();
await simulateWork(50);
console.log(`[${requestIdStorage.getStore()}] Finished request processing.`);
callback();
});
}
async function handleDatabaseQuery() {
console.log(`[${requestIdStorage.getStore()}] Performing database query.`);
await simulateWork(75);
console.log(`[${requestIdStorage.getStore()}] Database query complete.`);
}
function simulateWork(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// Simulate two concurrent requests
processRequest('req-123', () => {});
processRequest('req-456', () => {});
When you run this, you’ll see output like:
[req-123] Starting request processing.
[req-456] Starting request processing.
[req-123] Performing database query.
[req-456] Performing database query.
[req-123] Database query complete.
[req-123] Finished request processing.
[req-456] Database query complete.
[req-456] Finished request processing.
Notice how each log message correctly shows its associated request ID, even though handleDatabaseQuery is called asynchronously within processRequest. AsyncLocalStorage ensures that requestIdStorage.getStore() always returns the ID that was active when the current asynchronous execution context was entered.
The core problem AsyncLocalStorage solves is context propagation in a non-blocking, event-driven environment. In Node.js, callbacks and Promises allow code to run without blocking the main thread. However, this also means that the call stack doesn’t reliably track the origin of an operation. If you need to associate data (like a request ID, user ID, or transaction ID) with a series of operations that span multiple asynchronous boundaries, manually passing this data through every single callback or .then() chain becomes incredibly tedious and error-prone. Global variables are out of the question because they’d be shared across all concurrent requests, leading to data corruption.
AsyncLocalStorage provides a per-request (or per-context) storage mechanism that is automatically managed by the Node.js runtime. When you call storage.run(storeValue, callback), Node.js associates storeValue with the current asynchronous execution context. Any subsequent asynchronous operations that are initiated within that callback (or any function called by it, directly or indirectly) will have access to storeValue via storage.getStore(). When the callback finishes, or if an asynchronous operation initiated within it completes outside of its scope, the association is automatically undone. This happens thanks to Node.js’s async_hooks module, which tracks the lifecycle of asynchronous resources.
The primary lever you control is the storeValue you pass to storage.run(). This can be any JavaScript value: a string, a number, an object, an array, or even null. The most common pattern is to store an object containing multiple pieces of contextual information.
import { AsyncLocalStorage } from 'async_hooks';
const requestContextStorage = new AsyncLocalStorage();
function getRequestContext() {
return requestContextStorage.getStore() || {}; // Return empty object if no store is set
}
async function handleRequest(req, res) {
const requestId = generateUniqueId(); // Assume this function exists
const user = await fetchUserFromDatabase(req.headers.authorization);
await requestContextStorage.run({ requestId, user }, async () => {
// Now, any function called within this async context can access requestId and user
await logRequestStart();
await processRequestLogic(req);
await logRequestEnd(200);
res.end('OK');
});
}
async function logRequestStart() {
const { requestId, user } = getRequestContext();
console.log(`[${requestId}] Starting request for user: ${user.name}`);
}
async function processRequestLogic(req) {
// ... business logic ...
await performDatabaseOperation();
}
async function performDatabaseOperation() {
const { requestId } = getRequestContext();
console.log(`[${requestId}] Executing critical DB operation.`);
// ...
}
async function logRequestEnd(statusCode) {
const { requestId } = getRequestContext();
console.log(`[${requestId}] Request finished with status: ${statusCode}`);
}
// Example usage (simplified)
// http.createServer(handleRequest).listen(3000);
This allows you to centralize context retrieval, making your code cleaner and more resilient. The AsyncLocalStorage instance itself is typically created once at the application’s entry point and then imported and used throughout your codebase.
When you use storage.run(), the callback function and any continuations it creates (like those from await or Promise.then()) are bound to the storeValue. If you have an asynchronous operation that is initiated outside of a storage.run() block and then later awaited inside one, the storeValue will not be available. This is because the asynchronous resource was created in a context without a store. However, if an asynchronous operation is initiated inside a storage.run() block, its continuations will inherit the store, even if those continuations are executed by a different event loop tick or even on a different thread (though Node.js is single-threaded for JavaScript execution, internal C++ modules might use threads). The crucial point is that the initiation of the asynchronous operation determines the initial context.
The next hurdle is understanding how to correctly handle scenarios where you need to migrate or override context across different AsyncLocalStorage instances, which often comes up in complex microservice architectures or when integrating with third-party libraries that might also use async_hooks.