V8 procedure syscalls like fetch() block the V8 thread for their full duration, forcing the runtime to spawn additional instances for concurrent requests — each with its own OS thread and V8 isolate. This proposal adds async/await support for procedures so they can yield at host calls, multiplexing multiple in-flight procedures on a single V8 worker. Synchronous procedures continue to work unchanged; module authors opt in to async at their own pace.
Blocking procedure syscalls (fetch()) in the V8 runtime park the V8 thread for their full duration — up to 30s per request. When another operation arrives while an instance is blocked, ModuleInstanceManager creates a new V8 instance — spawning an OS thread, allocating a V8 isolate, and recompiling the module. The pool never shrinks, so instances accumulate at peak load.
For example, an LLM chat app where each message triggers a procedure calling an LLM API (30-60s response time). With 10 concurrent users, that's 10 blocked instances each holding an OS thread and V8 heap, plus additional instances for interleaved work. This grows with every concurrent long-running request and never reclaims.
PR #4663 addresses this for reducers, views, and lifecycle callbacks by moving them to a single-worker FIFO lane (JsInstanceLane). Procedures are deliberately left on the old pool because they block on rt.block_on(). V8 has native async/await and there's exactly one guest language to support, so async procedures are a natural fit. A file-by-file analysis of what the implementation would involve accompanies this issue (see comments below), along with a verification checklist, to help expedite this work.
The WASM runtime doesn't have this problem. Both runtimes call the same instance_env.http_request(), but the paths diverge at the syscall layer:
// WASM path (wasmtime/wasm_instance_env.rs) — yields via async host function
let result = async { env.instance_env.http_request(request, body)?.await }.await;
// V8 path (v8/syscall/common.rs) — blocks the thread
let (response, body) = rt.block_on(env.instance_env.http_request(request, body)?)?;
The WASM runtime uses SingleCoreExecutor backed by a tokio::task::LocalSet — a single-threaded async executor where multiple tasks are multiplexed cooperatively. When a procedure yields at an async host function, the executor polls other tasks. Wasmtime bridges synchronous WASM guest code to async host functions via stack switching.
V8 doesn't have stack switching, but it doesn't need it — native async/await serves the same purpose. #4663 brings the reducer path closer to this model (single worker, FIFO queue) but doesn't add the async yielding that would let procedures share the worker.
// Synchronous procedure — works as today, blocks V8 thread
(ctx) => {
const resp = ctx.http.fetch(url);
return resp.text();
}
// Async procedure — V8 thread is free during await
async (ctx) => {
const resp = await ctx.http.fetch(url);
return resp.text();
}
Both forms coexist. Synchronous procedures use the existing rt.block_on() path unchanged. Async procedures yield at await points. Module authors adopt async at their own pace — no migration required. Reducers remain synchronous and never yield, same as the WASM runtime.
The runtime detects async functions at registration time via fn.constructor.name === 'AsyncFunction' and automatically selects the async execution path, providing AsyncProcedureCtx (where fetch() returns Promise<Response> instead of SyncResponse). No explicit flag is needed:
export const myProc = spacetimedb.procedure(
{ url: t.string() },
t.string(),
async (ctx, { url }) => {
const resp = await ctx.http.fetch(url);
return resp.text();
}
);
withTx vulnerability (ships independently)The current withTx<T>(body: (ctx) => T): T signature silently accepts async callbacks — TypeScript infers T = Promise<X>, the call type-checks, and the transaction commits before the awaited body runs. This is a latent data corruption path that exists today.
Fix: a conditional type T extends Promise<any> ? never : T rejects async callbacks at compile time, plus a runtime thenable check as defense-in-depth. This can ship independently of async procedures.
The changes needed have been traced through the codebase. The full file-by-file analysis and verification checklist are in the comments below. Here's a summary of the key areas:
Event loop. The synchronous for request in request_rx.iter() loop in spawn_instance_worker() becomes an async event loop with tokio::task::LocalSet, FuturesUnordered for in-flight async futures, and v8::MicrotasksPolicy::kExplicit to control when Promise resolutions propagate. The scheduling priority (request channel biased, with non-blocking try_next t