guide/src/reference/jspi.md
WebAssembly JS Promise Integration (JSPI) lets Rust functions
suspend the WASM fiber while a JS Promise resolves, then resume — without
blocking the event loop. The result: you can call fully Promise-based
browser APIs from ordinary Rust code, with no async call chain required.
Experimental. JSPI support in wasm-bindgen is experimental and subject to change. Using
#[wasm_bindgen(jspi)],#[wasm_bindgen(suspending)], orjspi_block_on_promiseemits a compiler warning noting this status (silence it with#[allow(deprecated)]once acknowledged).
| Runtime | Enabled by default | Behind a flag |
|---|---|---|
| Chrome / Chromium | 137 | 119–136 (#enable-experimental-webassembly-jspi, or origin trial) |
| Firefox | 153 | 150–152 (javascript.options.wasm_js_promise_integration) |
| Safari | Technology Preview 238 | — |
| Node.js | 25 | 24 (--experimental-wasm-jspi) |
#[wasm_bindgen(jspi)] on exportsMarks a Rust export as suspendable: JS callers receive a Promise, and the
TypeScript signature reflects Promise<T>. Anywhere in the export's call
tree, a #[wasm_bindgen(suspending)] import call or jspi_block_on_promise can
suspend to the JS event loop.
use wasm_bindgen::prelude::*;
#[wasm_bindgen(jspi)]
pub fn compute() -> u32 {
// May call suspending imports / jspi_block_on_promise() internally
42
}
const result = await compute(); // Promise<number>
The attribute also composes with async fn: the JS contract is identical to
a plain async export, but the body's sync callees may suspend. Prefer plain
async fn unless the body actually reaches suspending sync code.
Returning Result rejects the returned promise with the Err value, for
both forms.
#[wasm_bindgen(suspending)] on importsMarks an imported JS function as suspending: calling it from within a
#[wasm_bindgen(jspi)] export suspends the fiber while the returned
Promise is pending. The declared return type is the type the promise
resolves to — the call returns the settled value directly, so a suspending
import is always a plain fn (async + suspending is a compile error):
#[wasm_bindgen]
extern "C" {
// JS: async function fetch_data() { ...; return "text"; }
#[wasm_bindgen(suspending)]
fn fetch_data() -> String;
}
All FromWasmAbi return types work — strings, numbers, Option<T>,
Vec<T>, imported JS types, etc.
catch composes with suspending exactly like it does elsewhere: a rejected
promise (or a synchronous throw from the JS function) surfaces as Err with
the rejection reason:
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(catch, suspending)]
fn fallible_fetch() -> Result<String, JsValue>;
}
Without catch, a rejection unwinds — running destructors under
panic=unwind — and rejects the export's promise with the original reason
(or terminates the instance when the abort handler is enabled), mirroring
non-catch synchronous imports.
jspi_block_on_promise — await a single Promisejs_sys::futures::jspi_block_on_promise (re-exported from
wasm-bindgen-futures) suspends until the given Promise settles, returning
the resolved value as Ok or the rejection reason as Err. It can be called
any number of times within a jspi export.
use js_sys::futures::jspi_block_on_promise;
use js_sys::Promise;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(jspi)]
pub fn fetch_and_return() -> String {
let promise: Promise = some_async_js_api();
let value = jspi_block_on_promise(&promise).expect_throw("fetch failed");
value.as_string().unwrap_or_default()
}
Concurrency composes at the promise level, because promises are eager —
the JS work starts when the call is made, not when you suspend on it. Start
several calls and suspend on each, or suspend once on Promise::all,
Promise::race, Promise::any, or Promise::all_settled. A Rust Future
is awaited by scheduling it on the ordinary executor and suspending on its
completion promise:
let value = jspi_block_on_promise(&wasm_bindgen_futures::future_to_promise(fut));
There is no executor-side API: spawn_local is context-aware. When called
from within a JSPI context — a #[wasm_bindgen(jspi)] export, or a task
itself spawned from one — the task's polls are entered through a
WebAssembly.promising boundary, so the whole call tree — including sync
callees — may suspend:
#[wasm_bindgen(jspi)]
pub fn start_background_work() {
wasm_bindgen_futures::spawn_local(async move {
let x = JsFuture::from(fetch_thing()).await; // ordinary await
let y = sync_helper_that_suspends(); // jspi_block_on_promise inside
// ...
});
}
The capability is inherited transitively down the spawn tree, and applies
equally to future_to_promise (which spawns internally). Library code using
plain spawn_local gains suspendability automatically when reached from a
JSPI context; outside one, jspi_block_on_promise in a task fails with a
SuspendError, since those polls have no suspendable stack underneath.
A suspension parks only that task's poll: the microtask queue, other tasks,
and the event loop all continue. If a poll unwinds — a panic under
panic=unwind, or the rethrown rejection of a non-catch suspending import
— destructors run, only that task is abandoned, and the failure surfaces as
an unhandled promise rejection carrying the original reason.
#[wasm_bindgen(jspi)] async fn exports work by the same inheritance: the
attribute roots a JSPI context on the export's activation, which the body's
internal spawn then inherits.
wasm-bindgen permits reentrancy — JS called through an import may
synchronously call back into wasm exports — and this can break Rust
invariants (e.g. a RefCell borrow or static mut access held across the
import call). JSPI is no different in this respect: reentrancy is fully
supported — multiple in-flight JSPI suspensions operate on separate stacks
without conflict — and every suspension point is additionally a reentrancy
point, since other exports, fibers, and tasks run while the fiber is
suspended.
As always, when holding lifetimes over a reentrancy point — a borrow live
across jspi_block_on_promise or a suspending import call — care must be taken
that reentrant code cannot observe or contend with the borrowed state.
JSPI support requires reference types (enabled by default since Rust
1.82) and a runtime with exception handling support; every JSPI-capable
engine ships both. Note that post-processing tools need EH enabled too
(e.g. wasm-opt --enable-exceptions, or disable wasm-opt in wasm-pack
builds).
JSPI is not supported together with threads/atomics (shared memories): JSPI itself is a single-threaded proposal. Building with both enabled is rejected by the CLI.
The jspi-opfs example demonstrates all four patterns: #[wasm_bindgen(jspi)]
exports, multiple sequential jspi_block_on_promise calls, cross-context
navigator.storage, and testing with Playwright.
JSPI exports require a JSPI-capable runtime (see the support table above).
CI runs all three JSPI examples (jspi, jspi-opfs, jspi-fetch-streams)
automatically via the Playwright test suite under Chrome.
cargo build -p wasm-bindgen-cli
cd examples/jspi-opfs
PATH="$(git rev-parse --show-toplevel)/target/debug:$PATH" npm run build
This produces a ready-to-serve examples/dist/jspi-opfs/ directory.
cd examples
pnpm install
PREBUILT_EXAMPLES=1 pnpm exec playwright test -g "jspi"
Serve the built output from any static HTTP server and open index.html
(the OPFS example needs a secure context — localhost or HTTPS — for
navigator.storage).
cd examples/dist/jspi-opfs
npx serve .
# then open http://localhost:3000/index.html in Chrome 137+
jspi exportA #[wasm_bindgen(suspending)] import may only be called while a
WebAssembly.promising frame is on the stack — i.e. transitively from a
#[wasm_bindgen(jspi)] export. Calling one from a plain export (or from the
module's start path) throws a SuspendError at the import boundary at
runtime.
This cannot easily be a compile error: "is this function only ever reached
from a jspi export?" is a whole-program reachability property, not a local
one. If you see a SuspendError, check that every path reaching the
suspending import originates in a jspi export.
On suspension the shadow stack is saved into the heap, and restored back
onto the stack on resume. Each #[wasm_bindgen(jspi)] export records the
shadow-stack watermark at entry — its base — and each
#[wasm_bindgen(suspending)] call copies the live region [SP, base) out
to a heap allocation and resets SP to the base before suspending. The first
instructions after resume copy the region back to its original address and
restore SP from a wasm local (which JSPI preserves), so interior stack
pointers remain fully valid. This is correct by construction: a Promise
resumption is always dispatched from the JS event loop, with an empty wasm
stack, so the restored region has the stack to itself — the address range is
time-multiplexed, and the only live data in it at any moment belongs to the
currently executing stack.
Reentrancy adds one wrinkle: a promising export can be entered over live frames (a sync export calls into JS, which calls the promising export), giving it a stack offset — its base sits partway down the stack. The offset is simply maintained; if the export then suspends, those leading frames unwind while it is pending, and after resume the region above its base is dead stack space, reclaimed when the promising call finally exits. There are thus two kinds of exit: a promising exit that never suspended, which may have a live parent stack region above it from reentrancy; and a promising exit that did suspend, which — by definition of being resumed from the JS event loop — has no live stack above it, only the dead stack space, which must be bumped off on exit.
Since the unsuspended exit requires no stack shift while the resumed exit
does, the difference is tracked with an internal __jspi_suspended global —
zeroed at entry, set on every resume, consulted at exit — which is correct
under single-threading for the current promising execution. And "resumed"
implies "really suspended" exactly, because JSPI performs promise resolution
on every Suspending return — even a non-Promise return suspends for a
tick.