Waiting for an async host by running the program again
How a synchronous QuickJS guest in WebAssembly waits for async Rust: abort on each host call, answer it, then replay the program with the answers preloaded.

QuickJS doesn’t know how to wait for Rust. It’s a synchronous interpreter, and in the system I’m describing it runs as a WebAssembly guest: quickjs-ng, the fork of Fabrice Bellard and Charlie Gordon’s engine, compiled to Wasm offline, carried as data and executed by tinywasm, a Rust interpreter vendored into a Ferrite application. Ferrite is my constrained application platform, and its rules are strict: one dependency, no unsafe code, no threads, no sockets, and everything an application can touch handed to it as an injected handle. Those handles are asynchronous. So when a snippet calls fetch, the guest needs an answer before it can execute its next instruction, and the only code that can get that answer is an async Rust function on the far side of a synchronous Wasm call.
There’s no thread to park the guest on, and this interpreter’s WASI imports have no way to suspend into an async Rust call. What an import can do is abort. That’s the idea behind this execution model: let the guest run until it needs the host, abort it, do the asynchronous work with no JavaScript on the stack, then run the whole program again from the top with the answer already waiting. I first used it in a constrained JavaScript worker I imported in July 2026, then had a coding agent extract the engine for a TanStack Start demo.
Abort, answer, run it again
A small prelude wraps the user’s code. Each host call takes the next index and goes out on stdout as a frame, marked with the sentinel \0FRPC\0 and carrying the index, the operation and its arguments, and then the prelude reads stdin for the response. The decision that makes everything work sits in the WASI shim’s fd_read: when stdin has run out and stdout holds more request frames than there are preloaded responses, the read traps instead of returning. Rust recognizes that trap as a pending call rather than a crash, parses stdout, takes the first request with no response yet, services it through the capability-checked host, where await is finally allowed, adds the response to a list and runs the program again with every response so far preloaded on stdin.
Take a snippet that reads one key from a store and then fetches a URL:
| Run | Guest | Host |
|---|---|---|
| 1 | Writes frame 0 (the store read), finds stdin empty | Catches the trap, performs the store read, records response 0 |
| 2 | Writes frame 0 again, reads response 0, carries on, writes frame 1 (the fetch), finds stdin empty | Catches the trap, skips frame 0 because it already has an answer, performs the fetch, records response 1 |
| 3 | Writes both frames again, reads both responses, finishes, writes its output frame | Returns the output: two host calls, three runs |
No JavaScript is running while the host waits. An async call of any length happens between two entirely synchronous runs of the guest, with no threads and no reentrancy, and the guest never learns that it was stopped. The price shows up in three places.
The first is cost. A program with N host calls runs N + 1 times, repeating everything before each new call, and a snippet that does expensive work before its first call pays for that work on every one of them. The package’s own documentation sets the expectation at milliseconds rather than microseconds, which is fine for an agent lending a snippet a few tools and would be the wrong design for anything hot.
The second is determinism. Responses are matched by position: the loop takes the request at responses.len() and doesn’t compare a replayed operation with the one it already answered. A run that took a different path would get an answer meant for a different call, and nothing would notice. Determinism is therefore a contract with the guest rather than something the loop enforces, and the WASI shim’s job is to make that contract easy to keep. There’s no filesystem, so opens fail with ENOENT and EBADF, there’s no network, output is captured and the clock is fixed, so every Date in a run gives the same instant.
The third is the meaning of “once”. Within a deterministic invocation, a request with an answer never gets dispatched again. But the answers live in a local Vec for the length of that invocation. If the process dies halfway through, the list dies with it, and a retry starts again from index zero, so a POST that already went out can go out a second time. The worker does keep a deduplication store with a 24-hour TTL, but that works on whole request envelopes, not on individual calls inside a run. Anyone who has used a durable-execution system will recognize the shape of this design, with the difference that their log survives a crash. What you get here is once per invocation; anything stronger needs a durable log or tools that are safe to repeat.
What bounds the guest
A guest that holds the only thread until it finishes needs a compute bound, and that’s what the interpreter’s fuel is for. The worker gives each synchronous run a default budget of 500 million units. Every replay starts with a fresh budget, so that number limits one run, not the accumulated cost of the invocation. Time spent in an asynchronous host call isn’t charged while the guest is stopped; egress therefore has a timeout of its own. Fuel also says nothing about memory: the manifest’s 128 MiB ceiling was declared but not enforced in this implementation.
What a snippet can call is decided per run. The agent chooses which of its own capabilities to lend, in a manifest that lists tools, stores, blobs, files and exact egress hosts, and anything not listed is denied. A manifest can only name bindings from an allowlist the application owner controls, and the handles for the application’s auth token and the interpreter blob are deliberately not on it. This check is where the security boundary sits. QuickJS in a Wasm interpreter isn’t an isolation boundary by itself, and it’s the host, refusing by default, that limits a snippet to the capabilities it was granted.
Server functions without a JavaScript server
The second use of the engine is TanStack Start, whose createServerFn(...).handler(...) lets an application define a function on the server and call it from the client through a generated stub. The aim was to run those handlers on Ferrite without embedding the SSR bundle or keeping a JavaScript server alive. The package added one entry point to what the worker already had, a variant that accepts extra prelude code, and the worker kept its own inlined copy, so these are two implementations of one design rather than a single shared library.
The seam is in the build output. TanStack Start compiles each server function into a provider module that attaches its id, name and filename, imports createServerFn from the SSR bundle under minified names and calls .handler(extractedRpc, originalUserFn), so the user’s original handler travels as the second argument. A build tool walks those modules and replaces that import with a shim createServerFn that records the method, the validator and the original function for each id, which leaves the SSR bundle out altogether. esbuild then packs the shim, the handlers and the application’s real seroval into one ES2020 script, emitted as a Rust constant. At run time a Rust route at /_serverFn/:id gives that script and the request to the engine and answers in the serialized format the client stub expects. Between requests there’s no JavaScript running at all.
These handlers are configured with 2 billion fuel units per synchronous run and 512 KiB of captured output. At this point the host behind them denied everything, so each host call came back capability_denied, and a server function could compute, validate and serialize but couldn’t reach storage or the network. The clock is a constant from July 2025, which gives every handler a consistent if slightly dated view of the world.
The way this path treats what it can’t do matters as much as what it runs. Middleware is refused at build time so it cannot be silently skipped at request time. An unrecognized import from the SSR bundle also stops the build. Multipart and form-encoded requests get a 501, a promise or stream in a result gets a 500, and a handler that runs out of fuel gets a 500 saying it exceeded its compute budget. An application author learns the limits of a narrow runtime from an error instead of a wrong answer.
The design works because it swaps a question a constrained host can’t answer, how to suspend a synchronous stack, for one it mostly can, how to make a program repeat itself exactly. A fixed clock, no ambient filesystem and captured output reduce variation on the program’s side of that, but the guest still has to follow a repeatable sequence of calls. What’s left is the world outside the guest, which remembers a POST whether or not the process that sent it lived long enough to write down the reply.
Chris Chabot, November 2026