ADR-0005: Use AsyncLocalStorage for Request-Scoped Context Blob
Status: Accepted
Date: 2025-11-30
1. Context
Our backend needs a reliable way to store and access request-scoped data (the "context blob") across multiple layers without threading parameters through every function. Typical data includes:
requestId/ correlation IDuserId, roles, permissionstenantId/ organization ID- locale and feature flags
- tracing and diagnostic identifiers
The system is multi-user and highly concurrent: a Node.js server handles many parallel HTTP requests (and potentially background jobs) using async/await and Promise-based I/O. We must ensure that:
- Each request has its own isolated context.
- Context follows the async call chain across our application and infrastructure layers.
- Context is easily available for logging, tracing, and authorization.
1.1 Technology assumptions
- Runtime: Node.js current LTS (≥ 20.x), which provides stable
AsyncLocalStorageinnode:async_hooks. - Language: TypeScript/JavaScript with
async/awaitas the primary async style. - Frameworks: HTTP framework(s) such as Express, Fastify, NestJS, or similar, providing per-request middleware hooks.
- Environment scope: The
AsyncLocalStorage-based context described in this ADR is Node-specific. It is implemented in the@speajus/diblob-async-contextpackage and is not intended to run in non-Node environments (for example, browsers or runtimes that do not providenode:async_hooks).
1.2 Constraints
- Must not leak data between concurrent requests or users.
- Must be compatible with
async/await, Promises, and typical Node I/O libraries. - Must be observable (usable from logging and tracing) and testable.
- Overhead must remain acceptable under high concurrency.
2. Decision
We will introduce a single shared AsyncLocalStorage instance that manages a request-scoped context blob. The blob is created and initialized at the start of each request, is visible throughout that request's async call tree, and is discarded when the request completes.
2.1 Contents of the context blob
The context blob will be a typed object. Initial fields include:
- Identification:
requestId, optional upstream IDs (e.g.,traceId,spanId). - User and tenant:
userId,tenantId, roles/permissions/scopes. - Behavior and localization:
locale, feature flags and experiment variants. - Observability metadata: any additional small key-value fields for logs/traces.
The shape can evolve, but data must remain per-request and small enough to avoid memory pressure.
2.2 Read/write rules
- Read access: Any backend code reads the current context through a blob proxy, not by talking to
AsyncLocalStoragedirectly.- We define a dedicated
RequestContextblob (e.g.,const requestContext = createBlob<RequestContext>('requestContext')). - When this blob is associated with an
AsyncLocalStorageContext, the blob proxy becomes the safe way to access the current context (for examplerequestContext.requestIdorrequestContext.userId).
- We define a dedicated
- Write access:
- Core fields (IDs, user, tenant, locale) are set in early request middleware when the per-request context is created.
- Additional fields may be set by well-defined components (e.g., auth, feature-flag, or tracing middleware) that intentionally extend the context object.
- Lifecycle window: Writes occur only during the request lifecycle. Long-lived or detached background work must establish its own context rather than relying on an HTTP request context.
3. Detailed Design
3.1 AsyncLocalStorage-backed context and blob proxies (@speajus/diblob-async-context)
Instead of exposing raw AsyncLocalStorage to the rest of the codebase, we introduce an AsyncLocalStorageContext type in a Node-specific package, @speajus/diblob-async-context. This type owns an AsyncLocalStorage instance and wires it to blob proxies:
- It manages an internal
AsyncLocalStorage<TContext>store, whereTContextis a generic object type provided by the caller. - It exposes a
runWithContext(context, handler)method that runs a handler inside a given context object. - It exposes a
registerWithContext(blob)method that associates a context blob (a diblobcreateBlob<TContext>()) with thisAsyncLocalStorageContext. - It lives in the Node-targeted
@speajus/diblob-async-contextpackage that depends directly onnode:async_hooks, keeping the core diblob packages platform-agnostic.
When a context blob has been registered with an AsyncLocalStorageContext:
- The blob proxy reads from
AsyncLocalStorageto obtain the current context instance. - Property access on the blob (for example
requestContext.requestId) forwards to the context object in the current async store. - Accessing the blob when no context is active fails fast (for example, by throwing an error), making the blob proxy the safe and canonical way to access the context.
Illustrative sketch (simplified, @speajus/diblob-async-context API):
import { AsyncLocalStorageContext } from "@speajus/diblob-async-context";
import { createBlob, type Blob, type Container } from "@speajus/diblob";
interface RequestContext {
requestId: string;
userId?: string;
tenantId?: string;
}
// Application-owned blob; not defined in @speajus/diblob-async-context
export const requestContext = createBlob<RequestContext>("requestContext");
// Infrastructure: generic async context manager
class AsyncLocalStorageContext<TContext extends object = object> {
constructor(private readonly container: Container) {}
runWithContext<TResult>(
context: TContext,
handler: () => Promise<TResult> | TResult,
): Promise<TResult> | TResult {
// Delegates to AsyncLocalStorage<TContext>.run
}
registerWithContext(blob: Blob<TContext>): void {
// Registers a singleton blob that proxies to the current async context store.
}
}The important property is behavioral: any blob associated with an AsyncLocalStorageContext becomes the supported, safe API for accessing the underlying context store. The actual shape of the context (RequestContext or otherwise) and the blob definition live in the application or feature package, not in @speajus/diblob-async-context.
3.2 Context creation and population
We will create the context blob in the earliest possible middleware for each incoming request:
- Generate a new
requestId(e.g., UUID or trace ID). - Extract authentication and tenant information from headers, tokens, or session (e.g.,
userId,tenantId, roles). - Extract other cross-cutting data such as locale, feature flags, and tracing headers.
- Build a
RequestContextobject from these values. - Call
asyncLocalStorageContext.runWithContext(context, () => next())(or framework equivalent) so that all downstream code for this request runs within the context.
3.3 Retrieving context downstream via blobs
Within the request's async chain, downstream code retrieves the context through the RequestContext blob proxy:
- Direct property access, e.g.
requestContext.requestIdorrequestContext.userId. - Small focused helpers like
fetchCurrentUserId()orfetchCurrentTenantId()that read from therequestContextblob to clarify intent.
Because the blob proxy is wired to AsyncLocalStorageContext, any attempt to use it outside of a valid async context fails fast, while code running inside a request sees the correct per-request data.
3.4 Lifecycle binding
- The context is valid only inside the function passed to
asyncLocalStorageContext.runWithContextand all async operations that are transitively awaited from it. - When the request handler and its awaited async work complete, the associated async execution ends and the context is no longer retrievable via the
requestContextblob (the blob will throw if used outside an active context). - We do not store
RequestContextobjects in global variables, caches, or static fields.
3.5 Multi-user safety and isolation
AsyncLocalStorage associates a store with each async execution tree. For our purposes:
- Each HTTP request starts a fresh async tree and obtains its own
RequestContextinstance viaAsyncLocalStorageContext. - Concurrent requests have separate trees and therefore separate contexts.
- The
requestContextblob proxy always resolves against the context associated with the current request's async execution, never that of a different request.
To avoid accidental cross-request sharing:
- We treat
RequestContextinstances as request-local and never reuse them. - We do not cache contexts or copy them into long-lived objects.
- Fields inside the context are either scalars or short-lived objects.
3.6 Runtime environments
- Node clustering (multiple processes): Each process has its own
AsyncLocalStorageinstance via the@speajus/diblob-async-contextpackage. Context never crosses process boundaries; any cross-process communication must serialize needed context fields explicitly. - Worker threads: Each worker thread has its own
AsyncLocalStorage. If we send work to workers, we pass relevant context fields as parameters/messages and, if needed, establish a new context in the worker. - Serverless on Node: For Node-based serverless handlers (e.g., AWS Lambda using the Node runtime), we treat each invocation as a request. The handler initializes a
RequestContextper invocation and runs the invocation logic insiderunWithContext. - Non-Node runtimes: This ADR does not define behavior for non-Node runtimes (for example, browser-based code or edge runtimes without
node:async_hooks). Those environments will require alternative context mechanisms or separate packages.
Where frameworks or libraries may break async context propagation (e.g., nonstandard Promise usage or callback-based APIs), we will:
- Prefer libraries known to be compatible with
AsyncLocalStorage. - Wrap problematic APIs so that new async work is created and awaited from within the existing context.
4. Security, Safety, and Reliability
4.1 Preventing cross-request data leakage
- A new context blob is created for each request and is not reused.
- We never store
RequestContextor its nested objects in global variables, caches, or other long-lived structures. - Detached jobs and scheduled tasks must not use an HTTP request context; they create their own context as needed.
We will include tests that simulate concurrent requests with different users and tenants, asserting that logs and behavior always reflect the correct context.
4.2 Error handling and rejected promises
- If errors occur within
asyncLocalStorageContext.runWithContext, the context remains available to error handlers in the same async tree, so they can logrequestId,userId, etc. via therequestContextblob. - Once the request finishes (successfully or with error), the async tree ends and the context is no longer accessible through the blob proxies.
- We will enforce coding standards to avoid unhandled rejected Promises and to always
awaitasync operations that are part of a request.
4.3 Memory safety
- We keep the context small (IDs and small metadata only).
- We avoid storing large payloads, streams, or long-lived references in the context.
- Since we do not hold global references to
RequestContext, the VM can garbage-collect context objects once the request's async tree completes.
4.4 Logging and tracing
- Logging utilities will fetch the current context and automatically attach fields like
requestId,userId, andtenantIdto log entries. - Tracing utilities will seed span and trace data from the context (or populate the context from incoming trace headers).
This provides consistent correlation for debugging and observability.
5. Alternatives Considered
5.1 Explicit context parameters
Description: Pass a context object explicitly through function parameters (e.g., fn(context, arg1, arg2)).
Pros:
- Very explicit and easy to reason about.
- No reliance on
AsyncLocalStorageor runtime features.
Cons:
- Intrusive: requires updating many function signatures across layers.
- Clutters APIs and makes refactoring harder.
- Easy to accidentally drop the context at some layer, causing inconsistent behavior.
Reason not chosen: The ergonomics and maintenance cost are high for a large, layered codebase with many cross-cutting concerns.
5.2 Global variables or singletons without AsyncLocalStorage
Description: Store context in mutable globals or singletons, perhaps keyed by request ID.
Pros:
- Simple to implement conceptually.
- No async hooks required.
Cons:
- High risk of cross-request leakage if cleanup is imperfect.
- Manual lifecycle management is error-prone under concurrency.
- Encourages unsafe global state patterns that complicate testing and reasoning.
Reason not chosen: Global mutable context is unsafe in a highly concurrent environment and does not meet our isolation guarantees.
5.3 Third-party context propagation libraries
Description: Use a library that abstracts context propagation (possibly wrapping AsyncLocalStorage or using custom mechanisms).
Pros:
- May offer additional features and integrations (e.g., tracing or DI).
Cons:
- Additional dependency surface and upgrade cost.
- Harder to debug if the library's propagation logic is opaque.
- Some libraries may lag Node.js LTS changes.
Reason not chosen: Node's built-in AsyncLocalStorage is sufficient and well-supported. A thin in-house wrapper keeps behavior transparent and debuggable.
6. Consequences
6.1 Positive consequences
- Cleaner function signatures without repeated context parameters.
- Consistent logging and tracing via implicit access to
requestId,userId, andtenantId. - Easier access to user, tenant, locale, and feature flags across layers.
- Clear separation of concerns: transport layers handle context creation; business logic consumes context.
6.2 Negative consequences and trade-offs
- Debugging context issues may require reasoning about async call chains and library behavior.
AsyncLocalStorageadds a small runtime overhead that may be noticeable at extreme load and must be monitored.- Functions may appear pure but implicitly depend on context, which can reduce transparency if not well documented.
- Some async patterns and libraries may require extra care to preserve context.
6.3 Limitations and mitigations
- Detached async work (fire-and-forget): Once control leaves the
asyncLocalStorageContext.runWithContextscope, the HTTP context is invalid. Detached tasks must receive explicit data and, if needed, create their own context. - Cross-process or cross-service calls: Context does not automatically cross process/machine boundaries. We must serialize relevant fields into headers or messages and reconstruct a new context on the receiving side.
- Incompatible libraries: For libraries that break async context, we will choose alternatives where possible or wrap them to run within the correct context scope.
7. Implementation and Migration
7.1 Implementation plan
- Add the AsyncLocalStorageContext type and generic context wiring: - Implement
AsyncLocalStorageContext<TContext extends object = object>owning the singletonAsyncLocalStorage<TContext>and providingrunWithContext()andregisterWithContext(). - Do not define a concrete context type or blob in@speajus/diblob-async-context; keep it purely generic infrastructure. - Add request initialization middleware for HTTP (and other transports as needed) to: - Define an application-specific context type (for example
RequestContext) and its blob viacreateBlob<TContext>()in the appropriate package. - Build the initial context object from headers, tokens, and environment. - Execute the rest of the pipeline insideasyncLocalStorageContext.runWithContext. - Integrate with logging and tracing, updating logging/tracing utilities to read from the
requestContextblob. - Adopt in new code, having new components access context only via the blob proxy (or small helpers that wrap it) instead of adding new context parameters.
7.2 Migration strategy
- Identify areas where
requestId,userId,tenantId, or similar are threaded through many layers. - Gradually refactor these call chains to rely on the context blob instead of explicit parameters, starting from controllers/handlers and moving inward.
- During migration, it is acceptable to keep both mechanisms temporarily (explicit parameters plus context) until behavior is verified, then simplify.
7.3 Testing
- Unit tests:
- Verify that
AsyncLocalStorageContext.runWithContextandregisterWithContextbehave as expected and that therequestContextblob throws when used outside the scoped handler.
- Verify that
- Integration tests:
- Simulate concurrent requests with different users/tenants and assert that logs, authorization checks, and behavior use the correct context when accessed through the blob proxies.
- Load tests:
- Run high-concurrency tests to confirm there is no cross-request leakage and that performance overhead is acceptable.
- Regression tests for known caveats:
- Add tests covering representative framework and library usage, including error paths and async patterns that could affect context propagation.
7.4 Example application: HTTP API with OAuth, async context, telemetry, and logger
This section sketches how a real Node HTTP API might combine:
@speajus/diblob-async-context(genericAsyncLocalStorageContextonly)@speajus/diblob-oauth(token verification)@speajus/diblob-telemetry(OpenTelemetry tracer/meter)@speajus/diblob-logger(structured logging)
The goal is to show how per-request context flows from the transport layer into business logic, and how logging and telemetry implicitly pick up requestId and identity information.
Illustrative (simplified) setup:
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { createBlob, createContainer } from '@speajus/diblob';
import { AsyncLocalStorageContext } from '@speajus/diblob-async-context';
import { logger, registerLoggerBlobs } from '@speajus/diblob-logger';
import {
registerTelemetryBlobs,
registerTelemetryLoggerBlobs,
telemetryContext,
} from '@speajus/diblob-telemetry';
import {
accessTokenVerifier,
oauthClientConfig,
registerAccessTokenVerifier,
registerOAuthClientConfigBlob,
registerOidcClientBlobs,
} from '@speajus/diblob-oauth';
interface RequestContext {
requestId: string;
userId?: string;
tenantId?: string;
}
// Application-owned context blob
const requestContext = createBlob<RequestContext>('requestContext');
// 1. Container and infrastructure blobs
const container = createContainer();
// Logging with optional Loki integration via telemetry
registerTelemetryLoggerBlobs(container, { serviceName: 'example-api' });
// Telemetry (tracer + meter)
registerTelemetryBlobs(container, {
serviceName: 'example-api',
exporter: 'console',
});
// OAuth/OIDC configuration and helpers
registerOAuthClientConfigBlob(container);
registerOidcClientBlobs(container);
registerAccessTokenVerifier(container);
// Async-local request context wiring: generic infrastructure
const asyncLocalStorageContext = new AsyncLocalStorageContext<RequestContext>(container);
asyncLocalStorageContext.registerWithContext(requestContext);
// Resolve commonly used blobs once at startup
const appLogger = await container.resolve(logger);
const telemetry = await container.resolve(telemetryContext);
const config = await container.resolve(oauthClientConfig);
const verifier = await container.resolve(accessTokenVerifier);
appLogger.info('example-api starting', {
issuerUrl: config.issuerUrl,
});
// 2. HTTP server: create RequestContext per request
const server = createServer(async (req, res) => {
const requestId = randomUUID();
// Extract bearer token (if present)
const authHeader = req.headers['authorization'];
const token =
typeof authHeader === 'string' && authHeader.startsWith('Bearer ')
? authHeader.slice('Bearer '.length)
: undefined;
const context: RequestContext = {
requestId,
userId: undefined,
tenantId: undefined,
};
await asyncLocalStorageContext.runWithContext(context, async () => {
const span = telemetry.tracer.startSpan('http.request', {
attributes: {
'http.method': req.method,
'http.target': req.url,
'http.request_id': requestContext.requestId,
},
});
try {
// Optionally authenticate via OAuth access token
if (token) {
const result = await verifier.verifyAccessToken(token, {
requiredScopes: ['openid'],
});
context.userId = result.subject;
context.tenantId = result.claims['tenant_id'] as string | undefined;
}
appLogger.info('Handling request', {
requestId: requestContext.requestId,
userId: requestContext.userId,
tenantId: requestContext.tenantId,
});
// Example route handler logic that relies on the context blob
if (req.url === '/me' && req.method === 'GET') {
if (!requestContext.userId) {
res.statusCode = 401;
res.end('Unauthorized');
return;
}
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify({
requestId: requestContext.requestId,
userId: requestContext.userId,
tenantId: requestContext.tenantId,
}),
);
return;
}
res.statusCode = 404;
res.end('Not Found');
} catch (error) {
const err = error as Error;
appLogger.error('Request failed', {
requestId: requestContext.requestId,
userId: requestContext.userId,
errorName: err.name,
errorMessage: err.message,
});
res.statusCode = 500;
res.end('Internal Server Error');
} finally {
span.end();
}
});
});
server.listen(3000, () => {
appLogger.info('example-api listening', { port: 3000 });
});In this example:
- The transport layer (HTTP server) is responsible for:
- Creating a
RequestContextper request (with a freshrequestId). - Optionally enriching that context with identity information after OAuth token verification.
- Running request handling logic inside
asyncLocalStorageContext.runWithContext.
- Creating a
- The context blob (
requestContext) is the single, safe way to access the current request context from any downstream code. - The logger and telemetry infrastructure naturally pick up
requestId,userId, andtenantIdvia the context blob, without needing to thread these fields through every call.
This ADR establishes AsyncLocalStorage (via @speajus/diblob-async-context) as the standard mechanism for managing request-scoped context in our Node backend applications, with clear rules, limitations, and a migration and testing plan.