AsyncLocalStorage: The Hidden Gem for Request Context in Node.js
AsyncLocalStorage provides context propagation across async operations without manual passing. Learn how to use it for request IDs, multi-tenant databases, and transaction management in modern Node.js applications.

One of Node.js's most underrated features is AsyncLocalStorage (ALS). It solves a decades-old problem: how to maintain context across asynchronous operations without threading it through every function call. If you've ever passed a requestId parameter through 10 layers of functions, ALS is your answer.
What Problem Does AsyncLocalStorage Solve?
Consider this common scenario: an HTTP request comes in, you generate a request ID, and you want every log line, database query, and error message to include that ID. Without ALS, you pass requestId everywhere:
// ❌ Manual propagation (error-prone, verbose)
function handleRequest(req, res, requestId) {
logger.info('Request started', { requestId });
processData(req.body, requestId);
}
function processData(data, requestId) {
db.query(data, requestId);
}
function db.query(sql, requestId) {
logger.debug('Executing query', { requestId, sql });
}
With ALS, the context is automatically available anywhere in the async chain:
// ✅ Automatic propagation
import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
function runWithRequest(req, callback) {
const context = { requestId: generateRequestId(), userId: req.user?.id };
als.run(context, () => callback());
}
// Anywhere in your code (no parameters needed)
function log(message) {
const context = als.getStore();
console.log(message, { requestId: context?.requestId });
}
Real-World Use Case #1: Request Logging Middleware
Create a middleware that automatically attaches request context to all logs in the request lifecycle:
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const requestContext = new AsyncLocalStorage();
// Express middleware
app.use((req, res, next) => {
const context = {
requestId: randomUUID(),
method: req.method,
url: req.url,
userId: req.user?.id,
ip: req.ip
};
requestContext.run(context, () => next());
});
// Logger that automatically includes context
const logger = {
info: (msg, meta = {}) => {
const ctx = requestContext.getStore() || {};
console.log(JSON.stringify({ level: 'info', msg, ...ctx, ...meta }));
}
};
// In your service layer — no context passing!
async function getUserOrders(userId) {
logger.info('Fetching user orders', { userId }); // requestId automatically attached
return db.query('SELECT * FROM orders WHERE user_id = $1', [userId]);
}
Use Case #2: Multi-Tenant Database Connections
SaaS applications need to switch database connections per tenant (or per request). ALS makes this clean:
const tenantContext = new AsyncLocalStorage();
async function getDbConnection() {
const tenant = tenantContext.getStore();
if (!tenant) throw new Error('No tenant context');
// Return tenant-specific connection from pool
return poolMap.get(tenant.id);
}
// Middleware to set tenant
app.use(async (req, res, next) => {
const tenant = await loadTenant(req.headers['x-tenant-id']);
tenantContext.run(tenant, () => next());
});
// Repository method
async function getProducts() {
const db = await getDbConnection(); // Automatically uses correct tenant DB
return db.query('SELECT * FROM products');
}
Use Case #3: Transaction Management
ALS can hold a database transaction across multiple service calls without passing the client object:
const transactionContext = new AsyncLocalStorage();
async function withTransaction(fn) {
const client = await db.getClient();
try {
await client.query('BEGIN');
const result = await transactionContext.run(client, () => fn());
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Service methods don't need client parameter
async function createOrder(orderData) {
const client = transactionContext.getStore();
const order = await client.query('INSERT INTO orders...');
await updateInventory(orderData.items); // also uses same transaction
return order;
}
// Usage
app.post('/orders', async (req, res) => {
const result = await withTransaction(() => createOrder(req.body));
res.json(result);
});
Performance Considerations
AsyncLocalStorage uses a native C++ implementation that is extremely efficient — roughly 5-10ns per getStore() call in Node.js 22+. However, there is a small memory overhead per async resource (about 40 bytes). For 10,000 concurrent requests, that's ~400KB — negligible.
Important: Do NOT use ALS for storing large objects or frequent writes. The context is immutable per async chain — treat it as read-only metadata.
Common Pitfalls
- Losing context with unmanaged callbacks: Native promises and async/await work perfectly. But
setTimeout,setImmediate, and event emitters preserve context automatically. Onlyprocess.nextTickand raw async hooks can break it. - Using ALS with
clustermodule: Context does NOT share across processes. Each cluster worker has its own ALS. - Overwriting context in nested runs: Each
als.run()creates a new scope. Inner runs can access outer viagetStore().
Alternatives and When Not to Use ALS
If you're using OpenTelemetry for distributed tracing, its context API is more powerful. For simple request IDs, many teams just pass a parameter. Use ALS when:
- You have deep call stacks (5+ levels)
- You can't modify third-party library code to accept context
- You need consistent logging/tracing across all async operations
Conclusion
AsyncLocalStorage is production-ready, performant, and one of the most powerful APIs in modern Node.js. It eliminates context-passing boilerplate, enables elegant multi-tenancy, and simplifies transaction management. Start by implementing request ID logging — you'll immediately see cleaner code and better debugging. Then explore tenant isolation and automatic transaction propagation.
Comments
Join the conversation — sign in to leave a comment.