async local execution context
rule
establish execution context once at every platform boundary; lower services get it via useContext(). context carries runtime-scoped dependencies and correlation facts — environment bindings, database handle, clock, request ID, structured logger, active trace. not feature props or arbitrary request data.
- removes dependency threading without hiding ownership.
- boundary owns establishing context; missing context fails immediately.
consequence of skipping:
- global mutable state (module-level
let database) — concurrent requests overwrite each other's runtime dependencies. - detached callbacks (SDK hooks, Workflow engines, event emitters) not wrapped with context use the most recently set value, silently corrupting isolated work.
- missing database correlation ID — failures unattributable to their original request.
- platform-boundary contract invisible — bugs blamed on the wrong service or "timing races."
why
runtime dependencies must reach every service; both obvious answers fail:
- threading through every signature — spreads platform concerns into domain code.
- module-level globals — concurrent requests overwrite bindings, failures detach from traces.
execution-scoped context at platform boundaries is the third option: signature-clean services, visible ownership at the boundary, missing context fails immediately instead of silently using the wrong runtime.
boundary contract
| boundary | must do | lower layers may do |
|---|---|---|
| HTTP, cron, queue, Durable Object, agent, Workflow run | call withContext before delegating. | call useContext() for execution-scoped facilities. |
| framework callback outside the caller's async chain | re-enter context around the callback. | assume context only after that wrapper. |
| domain service or repository | read database, logger, clock, and bindings from context. | never accept duplicated env, database, or logger props. |
| UI/API contract | pass domain input and output only. | never leak runtime bindings across the boundary. |
useContext() must throw when no context exists. a silent fallback database, global environment, or fabricated request ID makes failures impossible to attribute and lets a platform-boundary bug masquerade as valid work.
case study: detached process-step callbacks
one backend uses AsyncLocalStorage for a generated operation ID, database handle, structured logger, clock, and span scope; services call useContext() instead of receiving that machinery through every signature.
a managed-process step.do callback is invoked by the engine, not the parent function's await chain, so the original async-local store does not reach it. a createContextBoundStep wrapper re-enters context for every callback, attaches process-step trace identity, and handles durable step-record observability at one choke point.
lesson: context propagation is a platform contract, not "AsyncLocalStorage works everywhere." classify every asynchronous handoff as inherited or explicitly rebound.
implementation checklist
- list platform entrypoints; establish
withContextat each one. - keep the context type small, execution-scoped, dependency-oriented.
- make
useContextfail with the missing boundary and remediation. - wrap callbacks from Workflow engines, event emitters, SDK hooks, worker queues that do not preserve the async chain.
- add trace facts only at the boundary that can prove them.
- verify a normal request and the detached callback path separately.
do not
- global mutable
env, database, request, or logger state. - pass
envanddatabasethrough every domain function "just in case." - assume a framework callback inherits
AsyncLocalStoragewithout proving it. - store UI state, authorization decisions, or arbitrary feature objects in the execution context.
- catch missing context and continue with a fallback.
good and bad
bad
let database: Database;export function setDatabase(nextDatabase: Database) {database = nextDatabase;}export async function readWorkflowRun(workflowRunId: string) {return database.query.workflowRun.findFirst({ where: { workflowRunId } });}
global database: no request, trace, or platform-boundary owner; a detached callback can use the most recently assigned value.
good
export async function handleWorkflowRequest(request: Request) {return withContext({ database, requestId: createRequestId() }, () => readWorkflowRun(requestId));}export async function readWorkflowRun(workflowRunId: string) {return useContext().database.query.workflowRun.findFirst({where: { workflowRunId },});}
HTTP boundary establishes context once; a managed callback uses its own withContext wrapper before calling the same service.