JavaScript Async Context & AsyncLocalStorage: Request Context Propagation Without Parameter Drilling
JavaScript Async Context & AsyncLocalStorage: Request Context Propagation Without Parameter Drilling
Tracking context across asynchronous boundaries—request IDs, user sessions, trace spans—is one of the most deceptively difficult problems in JavaScript. Thread-local storage doesn't exist in single-threaded JavaScript, and closures create memory leaks when used naively for context propagation. AsyncLocalStorage (Node.js) and the emerging AsyncContext proposal (TC39 Stage 2) provide the primitive that's been missing: context that automatically flows through promise chains, async/await, timers, and event callbacks without explicit parameter passing.
The Problem: Context Loss Across Async Boundaries
// Naive approach: manual parameter drilling
async function handleRequest(req, res) {
const requestId = generateRequestId();
const user = await authenticateUser(req, requestId); // Pass requestId
const data = await fetchUserData(user.id, requestId); // Pass requestId
const result = await processData(data, requestId); // Pass requestId
await saveResult(result, requestId); // Pass requestId
res.json(result);
}
// Every function signature polluted with context
async function fetchUserData(userId, requestId) {
logger.info('Fetching user data', { requestId }); // Need requestId here
const result = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
return result;
}
// Problem 1: requestId must flow through EVERY function call
// Problem 2: Third-party libraries can't access requestId
// Problem 3: Callbacks and event handlers lose context
// Problem 4: Deeply nested calls become unwieldy
Why closures don't work:
// Closure approach: memory leaks and concurrency issues
let currentRequestId = null; // Global state = race conditions
async function handleRequest(req, res) {
currentRequestId = generateRequestId(); // What if concurrent requests?
// Request A sets currentRequestId = "req-A"
// Request B sets currentRequestId = "req-B"
// Request A's async operation resumes... sees "req-B"
await processRequest(); // Uses wrong requestId!
}
// Closure capture approach: works but creates leaks
function createRequestHandler(requestId) {
// This closure captures requestId
// But if callbacks are stored (event listeners), closure is never GC'd
return async function() {
logger.info('Processing', { requestId });
await doWork();
};
}
AsyncLocalStorage: Node.js Solution
Core API
const { AsyncLocalStorage } = require('async_hooks');
// Create a storage instance (one per context type)
const requestContext = new AsyncLocalStorage();
// Run code with context
requestContext.run({ requestId: 'req-123', userId: 'user-456' }, () => {
// Any code here can access context
console.log(requestContext.getStore()); // { requestId: 'req-123', userId: 'user-456' }
// Including async code
setTimeout(() => {
console.log(requestContext.getStore()); // Still { requestId: 'req-123', ... }
}, 1000);
// And nested function calls
doSomethingAsync();
});
async function doSomethingAsync() {
// Context flows through promise chains
await someAsyncOperation();
console.log(requestContext.getStore()); // Still { requestId: 'req-123', ... }
// And through callback-based APIs
fs.readFile('file.txt', (err, data) => {
console.log(requestContext.getStore()); // Still accessible!
});
}
How It Works: async_hooks Integration
┌─────────────────────────────────────────────────────────────────────────────┐
│ ASYNCLOCALSTORAGE INTERNALS │
└─────────────────────────────────────────────────────────────────────────────┘
Node.js maintains an "async resource" tree:
══════════════════════════════════════════
┌─────────────────┐
│ Root Context │
│ (no store) │
└────────┬────────┘
│
als.run({...}, callback)
│
┌────────▼────────┐
│ Async Scope 1 │◄─── requestContext.getStore() = {...}
│ store = {...} │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
setTimeout() await promise fs.readFile()
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Timer callback │ │ Promise cont. │ │ FS callback │
│ Inherits store │ │ Inherits store │ │ Inherits store │
└────────────────┘ └────────────────┘ └────────────────┘
Each async operation:
1. init(): Store current context as "trigger" context
2. before(): Restore trigger context before callback
3. after(): Restore previous context
4. destroy(): Cleanup
const async_hooks = require('async_hooks');
async_hooks.createHook({
init(asyncId, type, triggerAsyncId) {
// New async resource created
// Inherit context from trigger
},
before(asyncId) {
// About to execute callback
// Restore associated context
},
after(asyncId) {
// Callback finished
// Restore previous context
},
destroy(asyncId) {
// Async resource cleaned up
}
}).enable();
Performance Characteristics
// AsyncLocalStorage has overhead - measure before using everywhere
const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();
// Benchmark: context access
console.time('getStore-1M');
als.run({ id: 1 }, () => {
for (let i = 0; i < 1_000_000; i++) {
als.getStore(); // ~10-50ns per call
}
});
console.timeEnd('getStore-1M'); // ~50-100ms for 1M calls
// Benchmark: context creation
console.time('run-100K');
for (let i = 0; i < 100_000; i++) {
als.run({ id: i }, () => {}); // ~100-500ns per call
}
console.timeEnd('run-100K'); // ~50-100ms for 100K calls
// Memory: Each async operation stores context reference
// Deep async chains = more memory
// But context is shared, not copied
Production Patterns
Request Context Middleware
// Express middleware for request context
const { AsyncLocalStorage } = require('async_hooks');
const { v4: uuidv4 } = require('uuid');
const requestContext = new AsyncLocalStorage();
// Types for context
interface RequestStore {
requestId: string;
userId?: string;
traceId?: string;
spanId?: string;
startTime: number;
metadata: Map<string, unknown>;
}
// Middleware
function requestContextMiddleware(req, res, next) {
const store: RequestStore = {
requestId: req.headers['x-request-id'] || uuidv4(),
traceId: req.headers['x-trace-id'] || uuidv4(),
spanId: uuidv4(),
startTime: Date.now(),
metadata: new Map(),
};
// Add request ID to response headers
res.setHeader('X-Request-ID', store.requestId);
// Run rest of request handling in context
requestContext.run(store, () => {
next();
});
}
// Context accessor utility
function getRequestContext(): RequestStore | undefined {
return requestContext.getStore();
}
function getRequestId(): string {
return getRequestContext()?.requestId ?? 'unknown';
}
// Usage in any module
app.use(requestContextMiddleware);
app.get('/api/users', async (req, res) => {
// No need to pass requestId
const users = await userService.getAll();
res.json(users);
});
// userService.js - no request context in parameters
async function getAll() {
const requestId = getRequestId(); // Access from anywhere
logger.info('Fetching all users', { requestId });
const users = await db.query('SELECT * FROM users');
// Even in nested calls
await auditLog('user_list_accessed');
return users;
}
// auditLog.js
async function auditLog(action) {
const ctx = getRequestContext();
await db.insert('audit_logs', {
action,
requestId: ctx?.requestId,
userId: ctx?.userId,
timestamp: new Date(),
});
}
Distributed Tracing Integration
// OpenTelemetry-style trace context
const { AsyncLocalStorage } = require('async_hooks');
interface TraceContext {
traceId: string;
spanId: string;
parentSpanId?: string;
baggage: Map<string, string>;
}
const traceContext = new AsyncLocalStorage<TraceContext>();
// Create child span
function createSpan(name: string): Span {
const parent = traceContext.getStore();
const span: TraceContext = {
traceId: parent?.traceId ?? generateTraceId(),
spanId: generateSpanId(),
parentSpanId: parent?.spanId,
baggage: new Map(parent?.baggage),
};
return {
context: span,
run<T>(fn: () => T): T {
return traceContext.run(span, fn);
},
end() {
// Report span to collector
reportSpan(name, span, performance.now());
},
};
}
// Usage
async function handleRequest(req, res) {
const rootSpan = createSpan('http.request');
await rootSpan.run(async () => {
// Child spans automatically inherit parent
const dbSpan = createSpan('db.query');
await dbSpan.run(async () => {
await db.query('...');
dbSpan.end();
});
// External call propagates trace context
const fetchSpan = createSpan('http.client');
await fetchSpan.run(async () => {
const ctx = traceContext.getStore();
await fetch('https://api.example.com', {
headers: {
'traceparent': `00-${ctx.traceId}-${ctx.spanId}-01`,
'tracestate': serializeBaggage(ctx.baggage),
},
});
fetchSpan.end();
});
rootSpan.end();
});
}
Logger with Automatic Context
// Logger that automatically includes context
const { AsyncLocalStorage } = require('async_hooks');
const logContext = new AsyncLocalStorage();
class ContextualLogger {
private baseLogger: Logger;
constructor(baseLogger: Logger) {
this.baseLogger = baseLogger;
}
private enrichWithContext(data: object): object {
const ctx = logContext.getStore() || {};
return {
...ctx,
...data,
timestamp: new Date().toISOString(),
};
}
info(message: string, data: object = {}) {
this.baseLogger.info(message, this.enrichWithContext(data));
}
error(message: string, error: Error, data: object = {}) {
this.baseLogger.error(message, this.enrichWithContext({
...data,
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
}));
}
// Create child logger with additional context
child(additionalContext: object): ContextualLogger {
const childLogger = this.baseLogger.child(additionalContext);
return new ContextualLogger(childLogger);
}
}
// Middleware adds context
app.use((req, res, next) => {
logContext.run({
requestId: req.headers['x-request-id'],
method: req.method,
path: req.path,
userAgent: req.headers['user-agent'],
}, next);
});
// Usage - no need to pass context
const logger = new ContextualLogger(pino());
async function processPayment(amount) {
logger.info('Processing payment', { amount });
// Output: { requestId: "...", method: "POST", path: "/pay", amount: 100, ... }
try {
await chargeCard(amount);
logger.info('Payment successful');
} catch (error) {
logger.error('Payment failed', error);
throw error;
}
}
TC39 AsyncContext Proposal
Stage 2 API Design
// TC39 AsyncContext (Stage 2) - Browser-compatible
// Create a context variable
const requestIdContext = new AsyncContext.Variable();
const userContext = new AsyncContext.Variable();
// Run with value
requestIdContext.run('req-123', () => {
console.log(requestIdContext.get()); // 'req-123'
// Nested runs override for inner scope
requestIdContext.run('req-456', () => {
console.log(requestIdContext.get()); // 'req-456'
});
console.log(requestIdContext.get()); // Back to 'req-123'
});
// Multiple contexts work independently
requestIdContext.run('req-789', () => {
userContext.run({ id: 'user-1' }, () => {
console.log(requestIdContext.get()); // 'req-789'
console.log(userContext.get()); // { id: 'user-1' }
});
});
Snapshot for Deferred Execution
// AsyncContext.Snapshot captures current context state
const context = new AsyncContext.Variable();
context.run('original', () => {
// Capture context at this point
const snapshot = new AsyncContext.Snapshot();
// Later, restore context (even from different execution context)
queueMicrotask(() => {
// Normal: context would be undefined here
console.log(context.get()); // undefined
// With snapshot: restore captured context
snapshot.run(() => {
console.log(context.get()); // 'original'
});
});
});
// Use case: Event handlers
const clickContext = new AsyncContext.Variable();
button.addEventListener('click', () => {
const snapshot = new AsyncContext.Snapshot();
// This callback might be scheduled later
requestAnimationFrame(() => {
snapshot.run(() => {
// Context from click handler is available
updateUI();
});
});
});
Polyfill Implementation
// Simplified AsyncContext polyfill
class AsyncContextVariable {
#storage = new AsyncLocalStorage();
run(value, fn) {
return this.#storage.run(value, fn);
}
get() {
return this.#storage.getStore();
}
}
class AsyncContextSnapshot {
#captures = new Map();
constructor() {
// Capture all active contexts
for (const context of activeContexts) {
this.#captures.set(context, context.get());
}
}
run(fn) {
// Restore all captured contexts
const restore = (contexts, index, fn) => {
if (index >= contexts.length) return fn();
const [context, value] = contexts[index];
return context.run(value, () => restore(contexts, index + 1, fn));
};
return restore([...this.#captures.entries()], 0, fn);
}
}
Edge Cases and Gotchas
Context Loss Scenarios
// PROBLEM: Native callbacks may lose context
const als = new AsyncLocalStorage();
als.run({ id: 1 }, () => {
// These preserve context:
setTimeout(() => console.log(als.getStore())); // ✅ { id: 1 }
Promise.resolve().then(() => console.log(als.getStore())); // ✅ { id: 1 }
fs.readFile('x', () => console.log(als.getStore())); // ✅ { id: 1 }
// These might NOT preserve context (native code without async_hooks):
// Some C++ addons
// Some native database drivers
// Worker threads (separate context)
});
// PROBLEM: Unbound callbacks
als.run({ id: 1 }, () => {
const callback = () => console.log(als.getStore());
// Later, outside the run():
setTimeout(callback, 1000); // Might work in Node.js
// But storing callback and calling later = risky
});
// SOLUTION: Explicit binding
als.run({ id: 1 }, () => {
const store = als.getStore();
const boundCallback = () => {
// Manually restore if needed
als.run(store, () => {
doWork();
});
};
});
Worker Threads
// Worker threads don't share AsyncLocalStorage
const { Worker, isMainThread, parentPort } = require('worker_threads');
const { AsyncLocalStorage } = require('async_hooks');
const als = new AsyncLocalStorage();
if (isMainThread) {
als.run({ requestId: 'main-123' }, () => {
const worker = new Worker(__filename);
worker.on('message', (msg) => {
console.log('Main thread:', als.getStore()); // { requestId: 'main-123' }
console.log('Worker reported:', msg); // { requestId: undefined }
});
worker.postMessage({ task: 'work' });
});
} else {
parentPort.on('message', (msg) => {
// Worker has separate ALS context
console.log('Worker thread:', als.getStore()); // undefined
parentPort.postMessage({ requestId: als.getStore()?.requestId });
});
}
// SOLUTION: Pass context explicitly to workers
if (isMainThread) {
als.run({ requestId: 'main-123' }, () => {
const context = als.getStore();
worker.postMessage({ task: 'work', context });
});
} else {
parentPort.on('message', ({ task, context }) => {
als.run(context, () => {
// Now context is available in worker
doWork();
});
});
}
Memory Considerations
// Context objects are retained while async operations are pending
const als = new AsyncLocalStorage();
function potentialLeak() {
const largeData = new Array(1_000_000).fill('x');
als.run({ data: largeData }, async () => {
// largeData retained for entire async operation lifetime
await longRunningOperation(); // Hours?
// Even if we never access data again, it's retained
});
}
// BETTER: Store minimal context, fetch data when needed
function noLeak() {
const dataId = 'data-123';
als.run({ dataId }, async () => {
// Only ID retained, not actual data
await longRunningOperation();
// Fetch data only when needed
const data = await fetchData(als.getStore().dataId);
});
}
// Monitor context depth
function measureContextDepth() {
const asyncHooks = require('async_hooks');
let maxDepth = 0;
let currentDepth = 0;
asyncHooks.createHook({
init() { currentDepth++; maxDepth = Math.max(maxDepth, currentDepth); },
destroy() { currentDepth--; },
}).enable();
// Periodically log
setInterval(() => {
console.log('Max async context depth:', maxDepth);
maxDepth = 0;
}, 10000);
}
Framework Integration
NestJS Integration
// NestJS request-scoped context
import { Injectable, NestMiddleware } from '@nestjs/common';
import { AsyncLocalStorage } from 'async_hooks';
interface RequestContext {
requestId: string;
user?: User;
correlationId: string;
}
export const requestContext = new AsyncLocalStorage<RequestContext>();
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const context: RequestContext = {
requestId: req.headers['x-request-id'] as string ?? uuid(),
correlationId: req.headers['x-correlation-id'] as string ?? uuid(),
};
requestContext.run(context, () => next());
}
}
// Injectable service for type-safe access
@Injectable()
export class RequestContextService {
get current(): RequestContext {
const ctx = requestContext.getStore();
if (!ctx) throw new Error('No request context');
return ctx;
}
get requestId(): string {
return this.current.requestId;
}
setUser(user: User): void {
this.current.user = user;
}
}
// Usage in any service
@Injectable()
export class UserService {
constructor(
private ctx: RequestContextService,
private logger: LoggerService,
) {}
async findById(id: string): Promise<User> {
this.logger.log(`Finding user ${id}`, {
requestId: this.ctx.requestId, // Automatically available
});
return this.userRepo.findOne(id);
}
}
Fastify Integration
// Fastify with AsyncLocalStorage
import Fastify from 'fastify';
import { AsyncLocalStorage } from 'async_hooks';
const requestContext = new AsyncLocalStorage<{
requestId: string;
startTime: number;
}>();
const fastify = Fastify();
// Hook to establish context
fastify.addHook('onRequest', (request, reply, done) => {
const context = {
requestId: request.id,
startTime: Date.now(),
};
requestContext.run(context, done);
});
// Access context in route handlers
fastify.get('/users/:id', async (request, reply) => {
const ctx = requestContext.getStore()!;
const user = await fetchUser(request.params.id);
// Context available in nested calls
return user;
});
// Automatic request timing
fastify.addHook('onResponse', (request, reply, done) => {
const ctx = requestContext.getStore();
if (ctx) {
const duration = Date.now() - ctx.startTime;
metrics.requestDuration.observe(duration);
}
done();
});
Why This Matters in Production
Real-World Use Cases
- Request Tracing: Correlate all logs and metrics for a single request without parameter drilling
- Multi-Tenant Context: Pass tenant ID through entire request lifecycle
- Feature Flags: Evaluate flags based on context without passing user everywhere
- Audit Logging: Capture user/request info for all database operations
- Error Context: Include request details in error reports automatically
Performance Comparison
Without AsyncLocalStorage (parameter passing):
─────────────────────────────────────────────
Pro: Zero runtime overhead
Con: Cluttered function signatures
Con: Can't access context in third-party code
Con: Refactoring nightmare
With AsyncLocalStorage:
───────────────────────
Pro: Clean function signatures
Pro: Works across module boundaries
Pro: Third-party code can access context
Con: ~50-100ns per getStore() call
Con: Context retained for async lifetime
Recommendation:
- Use for request-scoped context (logging, tracing)
- Avoid for hot paths (millions of calls/second)
- Don't store large objects in context
- Use in Node.js; wait for TC39 AsyncContext in browsers
Debugging Context Issues
// Debug tool: trace context propagation
const { AsyncLocalStorage, AsyncResource } = require('async_hooks');
const debugAls = new AsyncLocalStorage();
// Wrap with debug info
debugAls.run({ id: 'test', created: new Error().stack }, async () => {
// If context is lost, check where it was created
const ctx = debugAls.getStore();
console.log('Context created at:', ctx?.created);
});
// Monitor all async operations
const asyncHooks = require('async_hooks');
const contexts = new Map();
asyncHooks.createHook({
init(asyncId, type, triggerAsyncId) {
contexts.set(asyncId, { type, trigger: triggerAsyncId });
},
destroy(asyncId) {
contexts.delete(asyncId);
},
}).enable();
// Debug: print active async contexts
setInterval(() => {
console.log('Active async contexts:', contexts.size);
for (const [id, info] of contexts) {
console.log(` ${id}: ${info.type} (triggered by ${info.trigger})`);
}
}, 5000);
AsyncLocalStorage and AsyncContext solve a fundamental gap in JavaScript's concurrency model. Unlike thread-local storage in threaded languages, they propagate context through the async execution graph—promises, callbacks, timers—without manual intervention. The tradeoff is modest runtime overhead for context access, but the architectural benefits (clean APIs, automatic propagation, cross-module access) make it essential for production observability and multi-tenant applications.
What did you think?