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.

The stack waits, the thread does not The module calls a suspending import, the engine parks its stack and returns to the event loop, other work runs, and when the promise settles the stack resumes exactly where it left off. module calls import looks synchronous stack parked control to the event loop other work runs rendering, input, timers resume same stack The module's locals, its call stack and its position in the loop all survive the suspension — which is precisely what Asyncify achieves by rewriting the module. Here the engine does it, so the binary is unchanged and there is no per-call instrumentation.

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.

Suspended is not busy While one call is suspended waiting on a promise, the module is idle and JavaScript can call into it again. A module whose state assumes one call at a time must be protected by serialising entries on the host side. run() — suspended, awaiting fetch reset() enters the module run() resumes into changed state The first call's assumptions about module state no longer hold, and nothing in its source suggests they might not. the fix: one in-flight call at a time, enforced by the host Asyncify has the same hazard for the same reason, and it is easy to miss in both because the module's code looks sequential.

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.

What JSPI changes Without stack switching, an import that needs to await must be restructured all the way up the call chain. With it, the engine suspends the whole stack and resumes it where it left off. without JSPI every caller becomes async the rewrite reaches the entry point with JSPI the import suspends callers are untouched and stay synchronous The win is largest for ported code, where making the call chain async would mean rewriting a library. Support is not universal — detect it and keep a restructured path, or the module simply fails to instantiate.

Gotchas

  • Assuming support. Narrower than most features here; detect and keep a fallback.
  • Reentrancy while suspended. Serialise entries or make the module reentrant.
  • WebAssembly.promising omitted. 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.

← Back to Async & Event-Loop Integration