AsyncLocalStorage gives you a way to maintain context across your async operations without manually passing data through every function. Think of it like having a secret storage box that follows your request around, carrying important information that any part of your code can access.
Here’s what a typical Express application without AsyncLocalStorage
might look like. We need to pass the userId
through multiple functions:
async function handleRequest(req, res) { const userId = req.headers['user-id'];
await validateUser(userId); await processOrder(userId); await sendNotification(userId);}
async function validateUser(userId) { // Need userId here}
async function processOrder(userId) { // Need userId here too await updateInventory(userId);}
async function updateInventory(userId) { // Still need userId here}
async function sendNotification(userId) { // And here}
Notice how we keep passing userId
everywhere? Now multiply this by several more parameters like requestId
, tenantId
, and locale
. The function signatures grow unwieldy fast.
Here’s how we can clean this up with AsyncLocalStorage
const { AsyncLocalStorage } = require('node:async_hooks');const storage = new AsyncLocalStorage();
const app = express();
app.use((req, res, next) => { const context = { userId: req.headers['user-id'], requestId: crypto.randomUUID(), startTime: Date.now() };
storage.run(context, () => { next(); });});
async function validateUser() { const context = storage.getStore(); console.log(`Validating user ${context.userId}`);}
async function processOrder() { const context = storage.getStore(); console.log(`Processing order for ${context.userId}`);}
async function sendNotification() { const context = storage.getStore(); console.log(`Sending notification to ${context.userId}`);}
app.post('/orders', async (req, res) => { await validateUser(); await processOrder(); await sendNotification();});
The context follows the request through its entire lifecycle. No more parameter passing.
AsyncLocalStorage
requires Node.js 23 or higher, so anyone still on older versions will need to use the --experimental-async-context-frame
flag.
When to Use AsyncLocalStorage
Here’s where AsyncLocalStorage
shines: tracking requests as they flow through your microservices. You can log the request ID, trace ID, and other metadata without passing them through every function.
const { AsyncLocalStorage } = require('node:async_hooks');const storage = new AsyncLocalStorage();
function setupRequestTracing(app) { app.use((req, res, next) => { const traceId = req.headers['x-trace-id'] || crypto.randomUUID();
storage.run({ traceId }, () => { res.setHeader('x-trace-id', traceId); next(); }); });}
function log(message) { const { traceId } = storage.getStore(); console.log(`[${traceId}] ${message}`);}
AsyncLocalStorage
keeps track of the current transaction across your database operations. This is useful when you need to pass the transaction object through multiple functions:
import { AsyncLocalStorage } from 'node:async_hooks';const asyncLocalStorage = new AsyncLocalStorage();
class TransactionManager { constructor() { this.storage = new AsyncLocalStorage(); }
async runInTransaction(callback) { const transaction = await db.beginTransaction();
try { await this.storage.run(transaction, callback); await transaction.commit(); } catch (error) { await transaction.rollback(); throw error; } }
getCurrentTransaction() { return this.storage.getStore(); }}
// Usageconst tm = new TransactionManager();
await tm.runInTransaction(async () => { await updateUserProfile(); await updateUserPreferences();});
async function updateUserProfile() { const transaction = tm.getCurrentTransaction(); await transaction.query('UPDATE users SET ...');}
AsyncLocalStorage
is also useful for logging. You can store the current log context and retrieve it in any function.
import { AsyncLocalStorage } from 'node:async_hooks';const logStorage = new AsyncLocalStorage();
// Setup middleware to create log contextapp.use((req, res, next) => { const logContext = { requestId: crypto.randomUUID(), userId: req.headers['user-id'], path: req.path, timestamp: new Date().toISOString() };
logStorage.run(logContext, () => { next(); });});
// Create a logger that uses the contextfunction logger(message, level = 'info') { const context = logStorage.getStore(); console.log(JSON.stringify({ level, message, requestId: context.requestId, userId: context.userId, path: context.path, timestamp: context.timestamp }));}
// Use it anywhere in your appapp.get('/api/users', async (req, res) => { logger('Fetching users list');
try { const users = await db.getUsers(); logger('Successfully retrieved users'); res.json(users); } catch (error) { logger('Failed to fetch users', 'error'); res.status(500).send(error.message); }});
When you run this, each log message automatically includes the full request context:
{ "level": "info", "message": "Fetching users list", "requestId": "123e4567-e89b-12d3-a456-426614174000", "userId": "user123", "path": "/api/users", "timestamp": "2024-12-20T10:30:00.000Z"}
When Not to Use AsyncLocalStorage
While AsyncLocalStorage
is powerful, I avoid it when:
- The context only needs to flow through a couple of functions - regular parameter passing is clearer.
- Working with synchronous code -
AsyncLocalStorage
adds unnecessary complexity. - When you build a public API using
AsyncLocalStorage
, you’re forcing a specific way of managing context onto your users. Consider a payment processing API:
import { AsyncLocalStorage } from 'node:async_hooks';const storage = new AsyncLocalStorage();
export class PaymentProcessor { async processPayment(amount) { const context = storage.getStore(); if (!context?.userId) { throw new Error('No user context found!'); } // Process payment using context.userId }}
Your API consumers now must wrap every call in storage.run()
, which might not fit their application’s architecture.
They can’t simply call processPayment()
directly with a user ID. Instead of writing straightforward code like await processor.processPayment(100, userId)
, they’re forced into this more complex pattern:
storage.run({ userId: '123' }, async () => { const processor = new PaymentProcessor(); await processor.processPayment(100);});
A more flexible approach would make the context optional while still supporting AsyncLocalStorage
export class PaymentProcessor { async processPayment(amount, context = storage.getStore()) { const userId = context?.userId; if (!userId) { throw new Error('No user context provided!'); } // Process payment using userId }}
This approach gives API consumers the freedom to choose their preferred approach while maintaining compatibility with AsyncLocalStorage
when needed. They can either use the context storage or pass parameters directly, fitting their specific use case and architecture.
AsyncLocalStorage
might seem like magic at first, but understanding its boundaries helps you use it effectively. When used appropriately, it significantly cleans up your code and makes context management a breeze.