HomeJournalThis post

WebAssembly JSPI for Async Browser Calls

Suspend one synchronous Wasm stack across a Promise import while preserving rejection, capability detection, cancellation, and fallback.

JP
JP Casabianca
AI Engineer and Product Designer · full-stack delivery · Bogotá

WebAssembly JSPI bridges a synchronous Wasm call stack across a Promise-returning JavaScript import without rewriting every frame into a manual async state machine. This tutorial exercises fulfillment and rejection with feature detection, then names the fallback separately whenever native suspension APIs do not execute.

WebAssembly JSPI connects two calling conventions

A synchronous Wasm export can call an imported function as though a result will return on the current stack, while many browser capabilities return Promises. JavaScript Promise Integration supplies wrappers that suspend and later resume the Wasm computation around that asynchronous boundary.

The JSPI overview explains the proposal's model. Identify exactly which imports may suspend and which exports become promising; wrapping everything obscures control flow and can change error expectations.

The committed WebAssembly JSPI lab uses a tiny magic-byte fixture and deterministic Promise behavior. It is educational path evidence, not a throughput benchmark, a full compiled module demonstration, or proof that every browser supports the same API surface.

The committed Wasm binary imports one i32-to-i32 function and exports a run function that calls it. Its magic, sections, import, export, and code body are instantiated rather than displayed as inert bytes. Module instantiation is a required receipt field on every execution path.

Native suspension sequenceA real Wasm export calls a Suspending import, pauses on a Promise, then resumes through a promising wrapper.Wasm runSuspending importPromisestack pausedcall 7async ×2resolve 14resume 14
  1. Instantiate the committed module with a WebAssembly.Suspending import.
  2. Wrap the exported run function with WebAssembly.promising.
  3. Call run with seven.
  4. Await the imported Promise while Wasm is suspended.
  5. Resume and assert the exported Promise yields fourteen.
Figure 1: Every native label corresponds to an instantiated module and completed suspended call.

Feature-detect the APIs you actually call

Check WebAssembly.Suspending and WebAssembly.promising as functions before constructing the native path. Capability detection belongs adjacent to execution because user agents, flags, embedder versions, and staged rollouts can differ from a static compatibility table.

The WebAssembly proposals registry listed JSPI at phase 4 when this article was verified, yet proposal stage does not guarantee availability in every shipped environment. Keep the support statement dated and the application fallback operational.

WebAssembly JSPI receipts should record both booleans and actualPath. A browser with one exposed constructor but a missing companion must choose fallback-promise-adapter rather than claiming partial native execution.

Native JSPI begins only when both WebAssembly.Suspending and WebAssembly.promising exist. A Suspending import doubles seven after a Promise resolves, and the promising export must resume with fourteen before native completion is true. Constructor presence alone is insufficient. Capability detection cannot substitute for invoking the wrapped export successfully.

Wrap suspending imports deliberately

A suspending import represents JavaScript code that may return a Promise and pause the Wasm stack until settlement. Keep its authority narrow, validate arguments before issuing effects, and document whether a synchronous return is permitted or normalized into a resolved Promise.

Promise-based Web APIs such as fetch, storage adapters, compression, or user-mediated capabilities also need cancellation and resource ownership. The wrapper should not let a suspended stack retain an unbounded response, listener, lock, or credential if the surrounding task ends.

Use browser CompressionStream exports as a separate API contract when that is the imported operation. WebAssembly JSPI changes call integration, not byte authorization, stream limits, content type, or output verification.

The same instantiated path switches to an expected rejection. The exported Promise must reject with fixture rejection; a TypeError, compile error, wrapper misuse, or different message becomes an unexpected FAIL rather than positive rejection evidence. The rejection receipt names both the expected and observed messages.

Expose a promising export to JavaScript

A promising wrapper converts the selected Wasm export into a JavaScript function whose completion is represented by a Promise. Callers must await it, handle rejection, and avoid assuming that Wasm work completed when the initial JavaScript invocation returned.

Preserve the original synchronous export only for call paths that cannot suspend. Mixing wrapped and unwrapped entry points under one ambiguous name makes it difficult to know whether errors arrive as thrown exceptions, Promise rejections, or traps.

The WebAssembly JSPI sequence figure keeps call, suspend, event-loop wait, resume, and settlement visible. That ordering is conceptual; the browser receipt is the only claim about which path executed in the current environment.

A plain Promise adapter runs as a distinct fallback even when native JSPI is available. Its receipt uses fallback-promise-adapter, so unsupported environments remain usable without laundering JavaScript async behavior into a Wasm suspension claim. Wasm async imports stay native-only evidence.

Capability and fallback splitOnly both JSPI constructors select native; every other capability combination selects a separately named Promise adapter.Suspending AND promising?fallback-promise-adapternative-jspiunsupportedboth present
Path labels
Suspending absentfallback-promise-adapter
promising absentfallback-promise-adapter
both present and native assertions passnative-jspi
both present but native throws unexpectedlyFAIL, never fallback
Figure 2: Capability detection cannot convert a native runtime error into fallback success.

Runnable artifact — Exercise fulfill and reject behavior through native JSPI when available or a separately labeled Promise adapter.

<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>WebAssembly JSPI suspension lab</title><style>:root{color-scheme:dark}*{box-sizing:border-box}body{font:16px/1.5 system-ui;max-width:960px;margin:auto;padding:24px;background:#101b24;color:#f5f3ea}button,select,a{font:inherit;padding:.72rem 1rem;margin:.35rem;border:2px solid #7ce5c3;border-radius:.6rem;background:#172b36;color:#fff}button:focus-visible,select:focus-visible,a:focus-visible{outline:4px solid #ffd66b;outline-offset:3px}canvas{width:100%;height:auto;border:1px solid #78909c;background:#081116}output{display:block;white-space:pre-wrap;padding:1rem;background:#081116;border-radius:.6rem;margin-top:1rem;overflow-wrap:anywhere}small{display:block;color:#b8cbd4}.exports{display:flex;flex-wrap:wrap;gap:.5rem}@media(prefers-reduced-motion:reduce){*,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}</style><main><h1>WebAssembly JSPI suspension lab</h1><p>Native is reported only after a real Wasm import suspends, fulfills, resumes, and propagates the expected rejection. Unsupported browsers run a separately named Promise adapter.</p><button id="run">Run fulfill and reject paths</button><output id="receipt" aria-live="polite"></output></main><script>const bytes=new Uint8Array([0,97,115,109,1,0,0,0,1,6,1,96,1,127,1,127,2,11,1,1,109,5,97,115,121,110,99,0,0,3,2,1,0,7,7,1,3,114,117,110,0,1,10,8,1,6,0,32,0,16,0,11]);const availability={Suspending:typeof WebAssembly.Suspending==="function",promising:typeof WebAssembly.promising==="function"};async function fallback(){const fulfill=await Promise.resolve(14);let rejection;try{await Promise.reject(new Error("fixture rejection"))}catch(error){rejection=error.message}return{path:"fallback-promise-adapter",fulfill,rejection,completed:fulfill===14&&rejection==="fixture rejection"}}async function native(){let mode="fulfill";const suspending=new WebAssembly.Suspending(async value=>{if(mode==="reject")throw new Error("fixture rejection");return value*2});const instance=await WebAssembly.instantiate(bytes,{m:{async:suspending}}),run=WebAssembly.promising(instance.instance.exports.run),fulfill=await run(7);mode="reject";let rejection;try{await run(7)}catch(error){rejection=error.message}return{path:"native-jspi",moduleInstantiated:true,fulfill,rejection,completed:fulfill===14&&rejection==="fixture rejection"}}async function execute(){try{const fallbackResult=await fallback();let nativeResult={supported:false,completed:false};if(availability.Suspending&&availability.promising)nativeResult={supported:true,...await native()};const actualPath=nativeResult.supported?"native-jspi":"fallback-promise-adapter",data={availability,actualPath,wasmMagic:[...bytes.slice(0,4)],native:nativeResult,fallback:fallbackResult};const pass=fallbackResult.completed&&(!nativeResult.supported||nativeResult.completed);receipt.dataset.execution=JSON.stringify(data);receipt.value=(pass?"PASS: ":"FAIL: ")+JSON.stringify(data,null,2)}catch(error){const data={availability,unexpectedError:error.name+": "+error.message};receipt.dataset.execution=JSON.stringify(data);receipt.value="FAIL: "+JSON.stringify(data)}}run.onclick=()=>void execute();void execute()</script></html>

Propagate rejection and traps without translation loss

Test a fulfilled Promise, rejected Promise, synchronous import return, import throw, Wasm trap before suspension, trap after resumption, and unsupported wrapper. Preserve the original causal category in logs while presenting a safe application error to the user.

Do not catch every failure and return zero or an empty buffer unless that sentinel is part of the Wasm ABI. Silent translation can make a failed network or permission request look like valid computation and move corruption farther from its cause.

WebAssembly JSPI in the lab emits PASS for the deliberate rejection only when the receipt identifies kind reject and retains the fixture error. Passing means propagation behaved as expected, not that the underlying operation succeeded.

WebAssembly JSPI evidence here covers one integer call and one rejection. It does not measure stack cost, cancellation, streaming compilation, multi-value results, reentrancy, or compatibility with a production toolchain. Those concerns need separately compiled fixtures and named browser versions.

Give cancellation one explicit owner

Suspension does not automatically define how an in-flight browser operation is cancelled. Pass an AbortSignal or domain cancellation handle into the JavaScript import, define who aborts it, and decide how that reason reaches the resumed or rejected Wasm call.

Use the AbortSignal cancellation pipeline to test cancellation before dispatch, during wait, after effect commit, and after settlement. Ensure listener removal and resource cleanup run on every branch, including a caller that abandons the promising export.

WebAssembly suspending can retain stack state while the Promise waits, so unbounded fan-out can retain substantial application state. Apply concurrency limits and deadlines outside the module rather than treating suspension as free scheduling.

The sequence diagram should be read literally: Wasm calls its import, JavaScript returns a Promise, native suspension pauses the Wasm stack, and completion either resumes with fourteen or propagates the named rejection. Every arrow maps to receipt state. Cancellation would add a new owner and a separately asserted terminal branch.

Keep the fallback a different program shape

When native APIs are absent, a JavaScript Promise adapter can orchestrate an asynchronous operation and invoke a non-suspending Wasm function before or after it. That fallback may deliver the same product outcome while having a different call graph, state ownership, and error boundary.

Do not label it emulated JSPI. Report fallback-promise-adapter, run the same result and failure assertions where meaningful, and test that application behavior remains acceptable without claiming identical stack semantics.

For componentized agent tools, WebAssembly Components packaging addresses typed capability boundaries beyond this call mechanism. WebAssembly JSPI does not grant imports, define component interfaces, or sandbox host effects by itself.

Cancellation ownership remains with the embedding application because the proposal does not manufacture an AbortSignal. A real integration should link abort state to the imported operation and record whether the suspended caller observes cancellation. The teaching fixture does not simulate it. Its absence is printed beside the completed fulfill and rejection checks.

Fulfill and reject contractThe same imported operation must yield fourteen in fulfill mode and propagate fixture rejection in reject mode.run(7)modeswitchfulfill → 14reject → fixture rejectionreceipt
Fulfillment invariant
module instantiated, import awaited, result equals fourteen.
Expected rejection invariant
the exported Promise rejects with exactly “fixture rejection”.
Harness failure
TypeError, compile failure, or any unrelated exception is stored as unexpectedError and fails.
Figure 3: Expected rejection is evidence only when unexpected runtime failures remain distinguishable.

Ship native and fallback receipts side by side

Archive browser version, capability booleans, wrapper construction result, requested case, actual path, fulfillment value or rejection category, cancellation status, and cleanup. Run the matrix on supported and unsupported environments, then retain a static explanation for readers who cannot execute either path.

When the Wasm result feeds WebGPU or another renderer, use minimal-surface rendering boundaries to keep computation validation and display fallback independent. A failed renderer should not rewrite a successful suspension receipt.

Run the WebAssembly JSPI lab's fulfill and reject buttons and inspect actualPath before interpreting output. Adoption is justified only when the native behavior is required, the fallback remains honest, and every asynchronous resource has one owner through settlement.

The semantic browser audit inspects moduleInstantiated, fulfill, rejection, completed, and actualPath. A native label without all native fields is rejected even if the visible output begins with PASS. Unexpected errors are never accepted. Fallback success remains explicitly different from native suspension evidence.

Measure retained work without calling it JSPI speed

If adoption is motivated by responsiveness or memory, build a separate benchmark around the actual module, import latency, suspension frequency, payload size, concurrency, and cancellation behavior. Compare native JSPI, the product fallback, and any transformed async build under the same browser, device, warm-up, and network fixtures.

Record end-to-end task time, active CPU, retained memory where tooling permits, long tasks, cancelled-resource cleanup, and result equality. Do not infer a mechanism advantage from the teaching lab, whose deterministic Promise and tiny fixture deliberately remove the workload that performance claims require.

WebAssembly JSPI may simplify source control flow even when measured speed is neutral, which can still justify adoption. Keep maintainability evidence—stack clarity, error propagation, generated code size, debugging, and fallback complexity—separate from runtime metrics so one attractive number does not absorb unrelated engineering tradeoffs.

Fallback support is a product choice rather than proposal evidence. If the adapter changes values or error mapping, its tests and prose update independently from the JSPI path to preserve an honest capability boundary.

Review reentrancy and host authority

A Promise can settle after other JavaScript has changed application state, disposed a component, revoked permission, or scheduled another call into the same module. Revalidate mutable assumptions after resumption, guard module state against unintended reentrancy, and attach an operation generation so a stale result cannot overwrite newer work.

Imports remain capabilities supplied by the host. Validate module identity, constrain URLs and byte sizes, authorize each effect before dispatch, and avoid exposing broad browser globals simply because a synchronous Wasm function is convenient to call; suspension preserves a stack, not a security boundary.

WebAssembly JSPI is ready for a product when suspension, reentrancy, cancellation, rejection, traps, fallback, and teardown all have tests. Archive the host capability map beside the module digest so later imports cannot quietly widen what the same Wasm bytes are allowed to do.

The revisit watches proposal stages and browser implementations, then recompiles this exact fixture. WebAssembly JSPI support is refreshed only after both fulfill and rejection paths complete in the observed engine.