Internals: async context
How runtime:context moves a value across a continuation, what that costs, and the three things it refuses to do.
For signatures see the API reference; for the patterns, the guide.
Two halves, split at a value neither parses
| Owns | |
|---|---|
crates/engine/src/async_context.rs | which mapping is current, and putting the right one back |
runtime:context (JS) | what a mapping is — the contexts, their values, the trace id |
The host moves an opaque JS value between scopes and never looks inside it. The module builds and reads it and never learns when a continuation runs. Neither half can disagree with the other about a representation only one of them has.
The mechanism
The current scope is a small record, [mapping, task, parent, trace, span], held in V8's continuation-preserved embedder data. V8 captures that value when a promise reaction is created and restores it when the reaction runs, entirely inside V8, with no callback into the host. So await, .then() and queueMicrotask carry the scope for what they cost a program with no contexts at all. It is the mechanism Chrome uses for task attribution and the one Node 24 moved AsyncLocalStorage onto.
Capture happens where the reaction is created, which is what makes propagation schedule-time rather than settle-time: p.then(f) captures where .then is written, and await p captures at the await. So run() with an async callback returns as soon as that callback first yields, and the scope is reinstalled on entry to each continuation, not held until the promise settles. Two requests can be in flight without one's scope outliving the other.
A record is made only when the scope changes: run(), a timer firing, a request, withTrace, a span. The module reads and copies it in JS, and crosses into the host only to fetch the current record and to install a new one. The host decodes a record only when it needs one: arming a timer, opening an op's span, capturing a rejection.
Timers and the unhandled-rejection record carry a capture explicitly: a timer's is taken when it is armed, and a repeating timer reuses that one on every firing rather than picking up whatever the loop happened to be holding.
What replaced what
The first version used V8's promise hook: every new promise was stamped with a record in a private slot, and every reaction job was bracketed with a swap. That fired for every promise in the program once the module was loaded, whether or not anything read a context, and made a bare await about 15 times slower. It was installed lazily for that reason, and runtime:workers and runtime:diagnostics both load the module. Continuation data has no per-promise cost, so there is nothing left to be lazy about.
Tasks
A task is a unit of work the host starts: the root, a timer firing, an inbound request. A promise continuation belongs to the task that scheduled it, so currentTask().id does not change at an await, and parentId names the task that started this one. That differs from Node's executionAsyncId(), which gives every promise an id of its own. A per-promise id needs a per-promise callback, and that callback was the cost that was removed.
runtime:http still checks whether the module has been loaded before minting a trace per request, so a server whose program cannot read one draws no entropy.
Copy-on-write, and why it is a Map copy
run() shallow-copies the value map for the new scope. That is the property the module exists for: a write in one branch is invisible to a concurrent sibling and to the parent, which a shared map silently loses the moment two requests interleave.
The copy is O(contexts), not O(async depth), and a program has a handful of contexts. A persistent structure would trade that for an allocation per node and only pays at a context count no real program has. Worth revisiting from a profile, not from first principles.
What propagation costs
| esrun | Node.js | Bun | Deno | |
|---|---|---|---|---|
await, module never imported | 137ns | — | — | — |
Bare await | 117ns | 151ns | 125ns | 110ns |
await inside a context scope | 295ns | 583ns | 189ns | 423ns |
| Cost of propagation | 178ns | 432ns | 64ns | 313ns |
The same workload everywhere: a value made current once, then read from inside a continuation. The last row is what run() and get() add over the same loop; the second is what every other await in the program pays once the module is loaded, which is the row the promise hook used to put an order of magnitude above the others.
EventTarget
A listener runs in the mapping of whoever called dispatchEvent. Nothing enforces this — dispatch is synchronous JS, so it falls out of the design — but it is a decision, and there is a test whose job is to stop someone "fixing" it.
Listeners are long-lived and usually registered at module scope. Capturing at addEventListener would pin a request's mapping to a listener that outlives the request, and the leak grows for as long as the process runs. bind(listener) is the opt-in, per listener, written where the lifetime is known.
There is no EventEmitter here to have set a competing expectation, which is part of why this is the boundary to state loudly rather than to split the difference on.
Error paths
| Runs in | |
|---|---|
unhandledrejection | the mapping where the rejection was created |
| uncaught error from a timer | the timer's own mapping |
FinalizationRegistry cleanup | the empty mapping, always |
The first is captured in the promise-reject callback, which runs synchronously inside the rejecting code; by dispatch time the request is long off the stack. Reporting a failure against the tenant and request that produced it is the whole point.
The third is not a capture at all. Cleanup runs as its own microtask, never nested inside a reaction job, and the loop returns to the root mapping once per tick — where no JS is on the stack and the answer is known rather than guessed. A cleanup callback that saw whatever GC happened to interrupt would be nondeterministic and would pin the values it named.
That per-tick reset earns its place for a second reason: a try/finally restore does not run when execution is terminated mid-callback — process.exit(), the watchdog, the heap guard — so without it a killed callback's mapping would still be current on the next turn.
Trace ids
32 lowercase hex characters: a W3C trace-context trace-id, so an exporter needs no translation.
| Where | |
|---|---|
| inbound HTTP request | minted per request |
| anywhere else | minted lazily on first read, once per agent |
withTrace(id, fn) | the only override |
| a spawned worker | its own; nothing is auto-injected |
An inbound traceparent is ignored unless serve({ trustTraceHeaders: true }). The header comes from whoever opened the connection, and a trace id is a correlation key that lands in logs and in every downstream request this one makes. Trusted by default, any client could stitch its requests into another tenant's trace, or replay one id across millions of requests and make a whole trace tree useless. Deny-by-default on untrusted input is the rule the rest of this runtime is built on. A malformed or all-zero id is never adopted, trusted or not.
What it is not
Not gated. The only runtime: module with no capability on any export. The gate exists for side effects reaching past the isolate; there are none here. A denial would not restrict a reach, it would corrupt an answer — an ORM that cannot see its own transaction opens a second one.
Not observability. Context is application behaviour: always on, never sampled, never filtered, because losing a value is a correctness bug and possibly a security one. Anything that may be sampled and dropped is a different concern with opposite requirements, and building the two as one mechanism is what made Node's async_hooks unfixable. If a diagnostics module lands, it will read a trace id from here and nothing will read back the other way: no context value will live on a diagnostics record or be reachable through a diagnostics export.
Not async_hooks. There is no per-operation JS callback, and no resource object is ever handed to JS. Exposing the raw handle is what pinned objects, perturbed GC and froze an internal representation into public API.
Removed from the Node surface
| Why | |
|---|---|
enterWith() | A scope with no end is the main source of leaked request state. snapshot() covers the legitimate uses with a scope that has one. |
exit(fn) | Already expressible: ctx.run(undefined, fn) is exactly what it did. |
disable() | A global kill switch breaks every consumer of every context at once, and no library can defend against another calling it. |
AsyncResource | Conflates context capture with lifecycle emission. |
What remains ports as a rename — new AsyncLocalStorage() → createContext(), .getStore() → .get(), .run() → .run(). The suite contains an ORM's implicit-transaction pattern ported that way, run as two requests in flight where one rolls back.
Not done
Language-level AsyncContext, async task interception, and error bubbling through async stacks are out of scope. So is any node:async_hooks shim.
See D88 for the decision record.