WebAssembly JSPI: async JS from sync Wasm

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.

WebAssembly.promising(export) + new WebAssembly.Suspending(import) JS caller gets a Promise wasm runs sync C / Rust stack parked fetch() pending stack resumed value returned event loop free while parked — no blocking
The Wasm stack is suspended and resumed by the engine — the compiled code stays synchronous.

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 declare JSPI_IMPORTS and JSPI_EXPORTS yourself. 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.

Related
→ SharedArrayBuffer and Atomics in JavaScript → Offloading game physics to a Web Worker → Portfolio & projects
What is JSPI in WebAssembly?

JavaScript Promise Integration lets a Wasm module call a Promise-returning JS import as if it were synchronous: the engine suspends the Wasm stack and resumes it with the resolved value. The API is two pieces — new WebAssembly.Suspending(fn) for the import and WebAssembly.promising(exportFn) for the export, which then returns a Promise.

Which browsers support WebAssembly JSPI?

Chrome and Edge from 137 (desktop and Android) and Firefox from 153. Safari and iOS don't support it in stable as of August 2026, which is why it isn't Baseline. Node.js has it behind --experimental-wasm-jspi.

JSPI vs Asyncify — which should I use?

Asyncify works everywhere but instruments the binary, costing roughly 50% in size and speed. JSPI uses native stack switching with no binary changes, but needs engine support and has higher per-call overhead in very chatty loops. In 2026, build both: JSPI where detected, Asyncify as the Safari fallback.

Why does my JSPI feature detection fail?

Old tutorials test "Suspender" in WebAssembly, an object that was removed from the proposal before it shipped. Test typeof WebAssembly.Suspending === "function" instead.