Calling Async JavaScript with JSPI
This guide answers one task: call a promise-returning JavaScript function from synchronous WebAssembly code — ported C, a blocking loop, a library that assumes it can wait — without rewriting it to be asynchronous.
Prerequisites
- [ ] An engine with the proposal: Chrome 126+ unflagged, others varying.
- [ ] Code that genuinely cannot be restructured, because restructuring is usually cheaper.
- [ ] A fallback, since support is narrower than for most features on this site.
- [ ] Understanding that this suspends the module’s stack, which has consequences.
What the proposal does
Ordinarily a WebAssembly call runs to completion. The JavaScript Promise Integration proposal adds two wrappers that change that.
WebAssembly.Suspending wraps an imported JavaScript function that returns a promise. When the module
calls it, the engine suspends the module’s stack and returns control to the event loop; when the promise
settles, the stack resumes with the result as the import’s return value.
WebAssembly.promising wraps an exported function so that calling it returns a promise, which settles when
the suspended computation finishes.
const imports = {
host: {
// the module calls this synchronously; the engine suspends until the promise settles
read_url: new WebAssembly.Suspending(async (ptrOut, cap) => {
const res = await fetch(currentUrl);
const bytes = new Uint8Array(await res.arrayBuffer());
const n = Math.min(bytes.length, cap);
new Uint8Array(memory.buffer, ptrOut, n).set(bytes.subarray(0, n));
return n;
}),
},
};
const { instance } = await WebAssembly.instantiate(bytes, imports);
const run = WebAssembly.promising(instance.exports.run);
const result = await run(); // the export now returns a promise
From the module’s point of view, read_url is an ordinary import that returns an integer. From
JavaScript’s, run is an ordinary async function. The engine reconciles them.
When restructuring is the better answer
Before adopting either mechanism it is worth asking whether the code can simply be changed, because when it can, the result is smaller, faster and portable to every engine.
A loop that reads and processes has a natural chunked form, where the host performs the reads and calls the module per chunk. That is the host-driven shape described in the topic overview, and it needs no suspension at all.
/* before: blocking, needs Asyncify or JSPI */
while (int n = read_chunk(buf, sizeof buf)) { process(buf, n); }
/* after: the host drives, no suspension needed */
int feed(const uint8_t *buf, size_t n) { return process(buf, n); }
The restructuring is worth its cost when the loop is yours, when the state it carries between iterations is modest, and when the module is intended to run outside a browser as well. It is not worth it when the loop is buried inside a third-party library, when the state is a deep call stack of a recursive parser, or when the port must ship before anyone has time.
That last category is exactly what these mechanisms exist for. Being explicit about which situation you are in prevents both the mistake of instrumenting a module that did not need it and the mistake of attempting a rewrite that was never going to finish.
Against Asyncify
Both solve the same problem and the comparison is stark.
Asyncify rewrites the module so that every function on a path that can suspend gains save-and-restore code plus a shadow stack. It works on every engine and it costs 30–100% in binary size and a measurable runtime overhead on every instrumented call, whether or not a suspension happens.
JSPI does the work in the engine. The binary is unchanged, there is no per-call overhead, and the cost is that the proposal must be supported.
same module, same workload
no async support 412 kB, cannot await
Asyncify (whole module) 768 kB, 14% slower on the non-suspending path
Asyncify (ASYNCIFY_ONLY) 501 kB, 4% slower
JSPI 412 kB, no measurable overhead
The middle row is why ASYNCIFY_ONLY matters: restricting instrumentation to the functions that actually
need it recovers most of the cost. The bottom row is why JSPI is worth adopting once support allows.
Building for it
For C and C++ with Emscripten, one flag selects JSPI instead of Asyncify, and the source using
emscripten_sleep and the asynchronous helpers is unchanged.
# Asyncify, the portable path
emcc app.c -sASYNCIFY -sASYNCIFY_ONLY='["main","read_loop"]' -O2 -o app.js
# JSPI, where supported
emcc app.c -sJSPI -O2 -o app.js
For a hand-written module, the wrappers are applied on the JavaScript side as shown above and the module itself declares an ordinary import. That means one binary can work either way, with the host deciding — which is a genuinely useful property when support is uneven.
const canSuspend = typeof WebAssembly.Suspending === 'function';
const readUrl = canSuspend
? new WebAssembly.Suspending(asyncRead)
: syncReadFromPrefetchedBuffer; // the fallback: fetch first, then call
What suspension does not change
Two things stay true and are worth stating because JSPI can create the impression that they do not.
The module is still single-threaded. Suspension yields the thread to the event loop; it does not run the module concurrently with anything. Two suspended computations resume one at a time.
And reentrancy remains a hazard. While the module is suspended, JavaScript may call into it again — a click handler, a timer, another request. If the module’s state assumes one call at a time, a second entry can corrupt it in ways that look impossible from reading the module’s own source.
// while run() is suspended awaiting fetch, this can enter the module again
button.onclick = () => instance.exports.reset(); // is that safe? probably not
Guarding against that means either serialising calls on the host side or making the module’s entry points reentrant, and the first is much easier. A simple “in flight” flag that rejects a second call while one is suspended prevents the whole class of problem.
Migrating from Asyncify
A module already using Asyncify can usually move to JSPI with modest changes, and doing it incrementally keeps a working build throughout.
The Emscripten path is mostly a flag change: -sASYNCIFY becomes -sJSPI, and the source using
emscripten_sleep, emscripten_fetch and the asynchronous helpers is unchanged. What differs is the
runtime requirement, so the build produces an artifact that needs a supporting engine.
Shipping both is the sensible intermediate state. Build twice, detect at load, and select — the same two-build arrangement used for any other proposal:
const url = typeof WebAssembly.Suspending === 'function'
? '/assets/app.jspi.a91c3f.wasm'
: '/assets/app.asyncify.a91c3f.wasm';
Report which one each session loaded. The Asyncify build is larger and slower, so the metric to watch is the share of traffic still receiving it — and when that share approaches zero, the second build and the instrumentation it carries can be deleted.
One migration detail catches people out: ASYNCIFY_ONLY lists have no equivalent under JSPI and should be
removed rather than left in place. They are ignored, and leaving them suggests to the next reader that the
JSPI build is instrumented when it is not.
Expected output
A working setup behaves like an ordinary async function while the module’s source stays synchronous:
[wasm] run() started
[wasm] read_url: suspending
[page] rendered a frame while suspended
[wasm] read_url: resumed with 4096 bytes
[wasm] run() returned 1
result: { ok: true, bytes: 4096 } in 148 ms
The line about rendering is the whole point: with Asyncify the same output appears, and without either the frame would not have happened.
Gotchas
- Assuming support. Narrower than most features here; detect and keep a fallback.
- Reentrancy while suspended. Serialise entries or make the module reentrant.
WebAssembly.promisingomitted. The export returns immediately with an unfinished computation.- Suspending in a context that forbids it. Some engines restrict suspension in certain call stacks; test the actual path.
- Treating it as concurrency. One computation runs at a time; suspension is not parallelism.
- Migrating from Asyncify without removing its flags. The two mechanisms should not be combined.
Performance note
For a module that awaited four times per operation, JSPI added no measurable overhead against a synchronous baseline, while Asyncify with whole-module instrumentation added 14% to every call and 86% to the binary. Restricting Asyncify to the two functions that suspend brought that to 4% and 22%. Suspension itself costs roughly the same as a promise resolution — a few microseconds — in both implementations.
Frequently Asked Questions
Should I adopt JSPI now?
If your targets support it and you have a fallback, yes — it is strictly better than Asyncify where it
works. If you need a single portable build today, Asyncify with ASYNCIFY_ONLY remains the pragmatic
choice.
Does this help Rust?
Rust code that is already async does not need it, because
wasm-bindgen-futures
handles that case with no engine support. It helps Rust that calls a blocking C library.
Can the module suspend inside a callback from JavaScript? Depends on the call stack, and engines differ. Test the specific path rather than assuming; a suspension that is refused surfaces as an error rather than silently blocking.
Related
- Awaiting JavaScript promises from Rust — the approach that needs no proposal.
- Porting a C game loop to Emscripten — where Asyncify usually first appears.
- Detecting proposal support at runtime — choosing between the two paths.
← Back to Async & Event-Loop Integration