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

boundarymust dolower layers may do
HTTP, cron, queue, Durable Object, agent, Workflow runcall withContext before delegating.call useContext() for execution-scoped facilities.
framework callback outside the caller's async chainre-enter context around the callback.assume context only after that wrapper.
domain service or repositoryread database, logger, clock, and bindings from context.never accept duplicated env, database, or logger props.
UI/API contractpass 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

  1. list platform entrypoints; establish withContext at each one.
  2. keep the context type small, execution-scoped, dependency-oriented.
  3. make useContext fail with the missing boundary and remediation.
  4. wrap callbacks from Workflow engines, event emitters, SDK hooks, worker queues that do not preserve the async chain.
  5. add trace facts only at the boundary that can prove them.
  6. verify a normal request and the detached callback path separately.

do not

  • global mutable env, database, request, or logger state.
  • pass env and database through every domain function "just in case."
  • assume a framework callback inherits AsyncLocalStorage without 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.

search pages

go to any page