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
V8's promise hook, on three of its four events:
| Event | |
|---|---|
Init | stamp the new promise with the mapping current now |
Before | install that mapping for the reaction job |
After | put back what was current |
Resolve | ignored — neither a scheduling point nor a continuation |
Stamping at Init is what makes propagation schedule-time rather than settle-time: p.then(f) creates its derived promise where .then is written, and await p creates its throwaway promise at the await. So run() with an async callback returns as soon as that callback first yields — the mapping 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.
The stamp is a private symbol on the promise, not a side table. A side table would have to be told when a promise dies and there is no such signal; a record hanging off the promise is collected with it.
Timers and the unhandled-rejection record carry the same 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.
Why it is off until imported
A promise hook is isolate-wide and fires for every promise. Installing one unconditionally would tax every program in the runtime, including the overwhelming majority that never reads a context.
So it is installed the first time the runtime serves the runtime:context module source — at compile, so the hook is in place for every promise the module graph creates on its way to running. Before that the mapping is empty everywhere, which is what such a program would observe either way.
runtime:http therefore does not import runtime:context. It reaches the per-request scope through the prelude's internal table and checks the hook is on first, so a server whose program uses contexts gets a trace per request, and one whose program does not mints nothing and 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 | 114ns | — | — | — |
Bare await | 2165ns | 135ns | 112ns | 99ns |
await inside a context scope | 2548ns | 552ns | 138ns | 418ns |
| Cost of propagation | 383ns | 417ns | 26ns | 319ns |
The same workload everywhere: a value made current once, then read from inside a continuation. Read the rows carefully, because the interesting number is not the last one.
The propagation itself is competitive — the last row is what run() and get() add over the same loop, and ours is in the same range as Node's.
The promise hook is not. Our second row is an order of magnitude above the others because importing the module installs an isolate-wide promise hook, and V8 then takes a slower path for every promise in the program — including the ones that have nothing to do with any context. Node reaches the same goal without that penalty. This is a real gap and it is the reason the first row exists: a program that never imports runtime:context has no hook and pays none of it, which is why the hook is installed lazily rather than always.
Measurement puts roughly 60% of the hook's cost in a single pair of SetPrivate/GetPrivate calls — the per-promise slot the capture is stamped into. Splitting that slot into cheaper pieces was tried and measured worse: it is the number of private accesses that costs, not the allocation. Closing the gap needs a cheaper per-promise slot than the public V8 API offers, which is the same thing Node solved by not using promise hooks at all.
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
The promise-hook overhead above. Deferred, and the most valuable open item in this module: it needs a cheaper per-promise slot than SetPrivate/GetPrivate, and the two obvious rearrangements were tried and measured worse. Anyone picking this up should start by reproducing the breakdown with bench/probe-tracing.sh and confirming the slot is still the dominant term.
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.