A C or Rust program compiled to WebAssembly is straight-line synchronous code. The browser's
most useful APIs — fetch, IndexedDB, the File System Access API — are all
Promise-based. For years, bridging that gap meant restructuring your program around callbacks
or paying for Binaryen's Asyncify transform. JSPI, the JavaScript Promise
Integration API, closes the gap in the engine itself: the Wasm stack simply suspends until the
Promise settles. This is what the API actually looks like in 2026, what it costs, and where it
runs.
The problem, concretely
Say you have a C function that reads a config file over HTTP and then keeps computing:
int load_and_score(const char *url) {
char *body = http_get(url); // wants to block
return score(body);
}
http_get has to call into JavaScript, and JavaScript's fetch returns
a Promise. Wasm has no way to wait for one. Historically you had three options: rewrite the C
so the network call is a continuation (invasive, and impossible for third-party code you
didn't write); do the I/O on a worker and block the main Wasm thread with
Atomics.wait on a SharedArrayBuffer
(works, but drags in cross-origin isolation and a worker architecture); or compile with
Asyncify, which rewrites the whole module so it can unwind and rewind its own stack.
What JSPI does instead
JSPI moves the suspension into the engine. When a Wasm module calls an import that is marked as suspending and that import returns a Promise, the engine detaches the module's entire call stack, returns control to the event loop, and attaches a callback to the Promise. When the Promise resolves, the stack is restored and the Wasm function continues from exactly where it was — with the resolved value as the import's return value. Your compiled code never knew anything happened.
The mirror image happens on the way in: the exported function you called can no longer return its result immediately, so JSPI wraps it to return a Promise instead. That Promise resolves when the Wasm computation finally finishes, and rejects if it throws.
The whole API is two functions
On the JavaScript side you wrap the async import and the export you call. That's it:
const importObj = {
env: {
http_get: new WebAssembly.Suspending(async (ptr, len) => {
const res = await fetch(readString(ptr, len));
return writeString(await res.text()); // returns a pointer
})
}
};
const { instance } = await WebAssembly.instantiateStreaming(fetch("app.wasm"), importObj);
const loadAndScore = WebAssembly.promising(instance.exports.load_and_score);
const score = await loadAndScore(urlPtr); // a real Promise
Two rules that trip people up. First, WebAssembly.Suspending is a constructor —
you must use new. Second, the wrapping only works one level deep in terms of
bookkeeping, not depth: the suspending import can be called from arbitrarily deep
inside the Wasm call stack, but the entry point into that stack must be the
promising-wrapped export. Call the raw export directly and the suspend attempt
fails with a WebAssembly.SuspendError — there is no suspendable stack to park.
Feature detection: don't copy old tutorials
A pre-2024 draft of the proposal had an explicit WebAssembly.Suspender object
that you created and threaded through both wrappers. It was removed. A lot of blog posts and
Stack Overflow answers still show it, and their feature test —
"Suspender" in WebAssembly — now returns false in every browser that
actually supports JSPI. Detect it like this instead:
const hasJSPI = typeof WebAssembly.Suspending === "function"
&& typeof WebAssembly.promising === "function";
Errors and rejection
If the imported Promise rejects, JSPI does not hand an error value back to your Wasm code. It throws the rejection reason as a Wasm exception into the suspended computation at the call site. For C or Rust builds without exception support compiled in, that usually means the whole call unwinds and the export's Promise rejects. If you want the classic error-code-in-a-return-value behaviour that C programs expect, catch inside the JavaScript import and return a sentinel yourself — that's the safest default when you're wrapping existing native code.
Emscripten: -sJSPI vs -sASYNCIFY
Most people meet JSPI through Emscripten rather than raw imports. The flags are close to
interchangeable at the command line — emcc -O3 app.c -sJSPI instead of
-sASYNCIFY — and the same emscripten_sleep()-style synchronous APIs
keep working. The differences that matter:
- Code size. Asyncify instruments the module so it can unwind and rewind; the docs put the overhead at roughly 50% in size and speed, and unoptimised builds are far worse. JSPI leaves the binary alone.
- Explicit lists. Asyncify infers which exports become async from a
whole-program analysis over
ASYNCIFY_IMPORTS. JSPI does not infer: you must declareJSPI_IMPORTSandJSPI_EXPORTSyourself. Forgetting an export is the single most common "why doesn't this suspend" bug in a ported build. - Embind behaviour differs. Under Asyncify an Embind export returns a Promise only if it actually suspended; under JSPI a function marked async always returns a Promise. Code that branches on the return type will behave differently between the two builds.
- Call overhead. Stack switching is not free per call. Emscripten's own issue tracker has a long-standing report of JS→Wasm→async-JS round trips being dramatically slower under JSPI than Asyncify in tight loops. If your workload is one big computation with a handful of I/O pauses, JSPI wins easily; if it is tens of thousands of tiny suspending calls, benchmark before you switch.
Where it runs, August 2026
JSPI reached phase 4 of the W3C WebAssembly process — effectively standardised. Shipping status:
- Chrome / Edge: stable since 137, desktop and Android.
- Firefox: shipped in 153 after riding the trains from Nightly.
- Safari / iOS: not supported in stable. This is what keeps JSPI out of Baseline.
- Node.js: behind
--experimental-wasm-jspi.
So the honest 2026 recommendation is: ship two builds. Use -sJSPI where
WebAssembly.Suspending exists, keep an Asyncify build as the fallback, and choose
at load time with the feature test above. That is annoying, but it is a build-config problem
rather than an architecture problem, and it disappears the day WebKit ships.
Is it worth the trouble?
If you are writing new code in JavaScript, no — you already have await. JSPI
exists for the large body of synchronous native code that people want to run in a browser
unmodified: SQLite and other embedded databases doing storage I/O, language interpreters,
emulators, CLI tools ported to the web, physics and simulation engines that occasionally need
to load an asset. For those, the alternative to JSPI isn't "write it async" — it's "rewrite
someone else's C library", which nobody does.
It is the same pattern as SharedArrayBuffer and Atomics or moving physics to a worker: the browser slowly grows the primitives that native runtimes always had, and each one removes a whole category of workaround. JSPI removes the ugliest workaround of them all.