Async Context: AsyncLocalStorage and async_hooks #
Status: active (planned) Last reviewed: 2026-10-20 Owner: theMackabu
Goal #
Implement node:async_hooks with Node 24 semantics: AsyncLocalStorage
propagating through every async continuation, a working AsyncResource, async
ids, and an opt-in createHook. Hard constraints:
- No growth of the promise state (
ant_promise_state_tstays 80 bytes; itsstatic_assertininclude/object.hstays). - Programs that never construct an
AsyncLocalStorageor enable a hook pay only a few instructions per continuation (target < 1% on the microbenchmarks below, measured as instructions retired). - No JIT changes.
Current State #
src/modules/async_hooks.c is a stub "just enough for react":
AsyncLocalStorage.runstores the value in the instance'sSLOT_DATAand restores it when the callback returns, so stores are lost at the firstawait,.then,queueMicrotask,nextTick, timer or I/O callback; only synchronous nesting works.AsyncResource.bindthrows ("Class constructor cannot be invoked without 'new'");runInAsyncScopejust calls the function.createHookreturns an object whoseenable/disabledo nothing;executionAsyncId()is 1,triggerAsyncId()0, ids on resources 0.napi_make_callbackignoresasync_context(src/napi/async.c:372);napi_async_initonly allocates a struct.
Probe (als.run('A', async () => ...)), Node 22 vs Ant:
node: sync=A after-await=A after-timer-await=A then=A microtask=A nextTick=A
fs-callback=A setImmediate=A setTimeout=A nested-timer=B bound=A
outside=- emitter-listener=-
ant: sync=A, everything else '-'
Baselines on this machine (instructions per op, async_paths.js, release
build, 2026-10-20): await ~1,070, .then ~3,900 (includes the derived
promise and closure), queueMicrotask ~550, setImmediate ~2,800.
Design #
Context frames #
One value per isolate, js->async_frame (mutable root; GC-marked):
- A frame is an immutable flat array
[storage0, store0, storage1, store1, ...], orundefinedfor the empty frame. Frames are small (one to three storages in practice), sogetStore()is a linear scan by identity. run(store, fn, ...args): copy the current frame with the entry for this storage replaced or appended, install it, callfn, restore the previous frame (also on throw).exit(fn)isrunwith the entry removed.enterWith(store)replacesjs->async_framefor the rest of the current synchronous execution and its continuations (Node semantics).disable()removes this storage from the current frame and marks the instance disabled sogetStore()returnsundefineduntil the nextrun.js->async_frame_used(bool) flips on the firstrun/enterWith/AsyncResource; gated paths below test it.AsyncLocalStorage.snapshot()returns a function that runs its argument in the frame captured at snapshot time;AsyncLocalStorage.bind(fn)issnapshotapplied tofn.
This matches Node 24's AsyncContextFrame and the TC39 AsyncContext proposal,
so the observable behaviour (including EventEmitter listeners running in the
emitter's context, and unhandledRejection running in the rejecting
context) follows Node 24, not the older async_hooks-based ALS.
Capture and restore points #
The census of async entry points (2026-10-20) found no single "make callback" helper, but nearly every continuation funnels through a handful of places.
Tier 1, core (covers awaits, promises, queues, timers and every promise-based native API):
| Continuation | Capture | Restore | Storage |
|---|---|---|---|
await (interpreter, JIT, inline async, async generators) |
js_promise_await_coroutine (src/modules/promise.c:330), the only await registration path |
resume_coroutine_if_suspended (src/sugar.c), next to coroutine_activate/coroutine_deactivate |
new ant_value_t async_frame on coroutine_t (include/sugar.h:18); coroutines are calloc'd and not size-asserted |
.then / .catch / .finally, Ant_Promise_Observe, js_promise_then |
builtin_promise_then and the C helpers, only when async_frame_used and the frame is non-empty |
js_process_promise_handlers (src/modules/promise.c:402) around the callback call |
an internal slot SLOT_ASYNC_FRAME on the derived promise (nextPromise); extra slots live in the object sidecar, so promises without a captured frame are unchanged |
queueMicrotask, process.nextTick, thenable jobs, stream_schedule_microtask, deferred dns/zlib/tls jobs |
queue_job / queue_microtask* / queue_next_tick* (src/modules/timer.c:805-919) |
process_microtask_entry (timer.c:951) |
ant_value_t async_frame on microtask_entry_t (timer.c:63; short-lived malloc'd entries, also used by the per-realm queue in ant_realm_t.microtasks) |
setTimeout / setInterval |
js_set_timeout / js_set_interval (timer.c:425/460) |
timer_callback (timer.c:365) |
field on timer_entry_t (timer.c:27) |
setImmediate |
js_set_immediate (timer.c:506) |
process_immediates (timer.c:1154) |
field on immediate_entry_t (timer.c:76) |
timers/promises, fs/promises and the fs callback API (wrapper over Ant_Promise_Observe, fs.c:5816), dns.promises, fetch, WHATWG streams, Ant.serve results, readline/promises, Atomics.waitAsync, navigator.locks, rpc client, isolates |
already promise reactions | via the .then row |
none |
ant:cron |
job creation | cron_timer_callback (src/modules/cron.c:889) |
field on cron_job_t |
MT_AWAIT_RESUME* and PROMISE_ALL_ELEMENT handlers do not call user code
directly; the await rows cover them. Sync generators are untouched (their
next() runs in the caller's frame, as in Node). settle_tail_await resolves
without resuming, so it needs no swap.
Tier 2, resources (Node runs these callbacks in the frame active when the resource was created):
| Source | Swap site | Frame stored on |
|---|---|---|
net sockets/servers (src/modules/net.c) |
net_emit (:133; 13 sites), sync write/end callbacks (:835, :861), listening via nextTick (:1046) |
socket / server struct |
tls (src/modules/tls.c) |
tls_emit (:133; 16 sites), tls_socket_write_cb (:486) |
tls socket struct |
child_process (src/modules/child_process.c) |
emit_event (:158), on_child_write_done (:555), child_stream_call_callback (:767), exec close listener (:1496, :1569) |
child struct |
dns callback API (src/modules/dns/request.c) |
dns_request_deliver (:45) |
dns_request_t |
fs native-callback ops (src/modules/fs.c) |
fs_call_callback (:232; fsync, read and similar) |
fs_request_s |
| fs.watch / watchFile / fs streams | fs_watcher_emit_change/error (:997/:1006), fs_watcher_invoke_watchfile_stats (:1025), fs_stream_callback (:300) |
watcher / stream state |
worker_threads (src/modules/worker/*.c) |
worker_parent_wake (core.c:469), node_worker_emit (node.c:20), web.c:19, mailbox_wake (messaging.c:186), worker_stdin_call (stdio.c:100) |
worker struct |
MessagePort / BroadcastChannel (src/modules/message_port.c) |
port_dispatch (:293), broadcast_async_cb (:785) |
port / channel |
process signals, stdin, SIGWINCH (src/modules/process.c) |
emit_process_event (:327), stdin (:773-809), :875 |
frame at listener registration is not Node semantics; use the root frame (Node runs signal handlers in the empty context) |
readline (src/modules/readline.c) |
emit_event (:428), on_sigint (:577) |
interface struct |
| tty | stream_readable_push paths (:127, :177) |
stream |
| WebSocket / EventSource | websocket_emit_* (:254/:287/:311), eventsource_emit (:70) |
socket / source struct |
| AbortSignal.timeout | abort_timeout_fire_cb (src/modules/abort.c:257) |
timeout record |
| navigator.locks, rpc server | lock_run_callback (navigator.c:392), rpc_server_async_cb (rpc.c:583) |
request / route |
| N-API | napi_make_callback (src/napi/async.c:372) honours async_context; napi_async_init captures the frame; tsfn call_js_cb (:35) and napi_async_work_after_cb (:91) restore the frame captured at creation |
napi_async_context struct, tsfn, work |
| ffi callbacks | src/modules/ffi.c:897 |
root frame (foreign threads have no JS context) |
Each module gets one helper (ant_async_frame_enter/leave, inline, two loads
and two stores) around its existing emit helper rather than per call site.
EventEmitter and EventTarget dispatch need no change: listeners run in the
dispatcher's frame, which is Node's behaviour. eventemitter_call_listener_inline
calls closures directly (bypassing sv_vm_call), which is fine because the
swap happens in the module helper outside it.
zlib's zlib_async_fn and the crypto callbacks run synchronously today, so
they inherit the caller's frame with no work.
AsyncResource #
- Constructor captures
js->async_frame(and, with Tier 3, assigns anasyncId/triggerAsyncId). runInAsyncScope(fn, thisArg, ...args)installs the captured frame, calls, restores.AsyncResource.bind(fn, type, thisArg)andresource.bind(fn, thisArg)return functions that run in the captured frame; fix the current throw (the stub constructs the class withoutnew).emitDestroy()calls Tier 3destroyhooks when enabled.events.EventEmitterAsyncResourceon top of it.
async ids (Tier 3) #
executionAsyncId()/triggerAsyncId()/executionAsyncResource(): keepjs->async_exec_id/js->async_trigger_idswapped at the same points as the frame. Ids are assigned lazily: resources only get one when a hook is enabled or an id is requested (resource.asyncId()), from a per-isolate 64-bit counter. Promises can reuse the existingpromise_id(ant_promise_state_t) andtrigger_parentwithout new fields.- Until Tier 3 lands, return stable, documented approximations (top-level execution 1, resources 0) as today.
createHook (Tier 3, opt-in) #
createHook({ init, before, after, destroy, promiseResolve })withenable()/disable();js->async_hooks_enabledgates every hook path, so the cost is zero while no hook is enabled.initat resource creation (the Tier 1/2 capture points),before/afteraround the restore points,promiseResolveinjs_resolve_promise.destroyfor promises needs a finalizer per tracked promise; only register it while a hook withdestroyis enabled. Resource structs calldestroyon close.- Node marks
createHook,AsyncHookandexecutionAsyncResourceas discouraged; implement them last and keep them off the fast paths entirely.
Phases #
- Frames and
AsyncLocalStorageAPI (run,exit,enterWith,getStore,disable,snapshot,bind); Tier 1 capture points;AsyncResource(runInAsyncScope,bind, fixed). Measure each capture point against the baselines before moving on. - Tier 2 resources, one module at a time, each with a Node-parity test (server request context across sockets, child process events, worker messages, readline, fs.watch).
- N-API
async_contextand threadsafe functions. - Async ids and
executionAsyncResource. createHookand promise hooks behindasync_hooks_enabled.
Performance Budget and Measurement #
- Instructions retired per op (
/usr/bin/time -l, min of 3, startup subtracted) forawait,.then,queueMicrotask,setImmediate,setTimeout, and a socket echo, compared to the baselines above; no-ALS regressions must stay under 1% per path, and bench-v8 must be unchanged. - With one active storage: report the per-path overhead (expected: one slot
write per
.then, one frame copy perrun) and compare with Node 22/24 on the same probes. ant_promise_state_tsize assertion unchanged; checksizeof(coroutine_t)andmicrotask_entry_tgrowth in the plan update.
Validation #
- Node-parity tests (
node:assert, pass on Node 24 and Ant): propagation through every Tier 1 row, nestedrun,exit,enterWithinside async functions,snapshot/bind,AsyncResourcescope and bind, thenables,Promise.all/race/any, async generators, unhandled rejections, frames acrossvmrealms, and isolation between workers. - Per Tier 2 module: callback context equals the context at resource creation.
- GC: frames captured in queues, timers, coroutines and promise slots stay
alive; ASan and
ANT_GC_STRESSruns; TSan for worker paths. - Spec suite, harness, and the full
tests/test_*comparison against the previous binary.
Open Questions #
- Node 22 vs 24 differences (22 uses
async_hooks-backed ALS by default; 24 uses AsyncContextFrame): target 24 and note where 22 differs in tests. - Whether
enterWithshould leak into the caller after anawaitin the same async function (Node 24: it does for the remainder of that function's continuations only). - Whether to expose the TC39
AsyncContext.Variable/Snapshotglobals on the same frames once the proposal stabilises.