
How to Await JavaScript Promises in WebAssembly Without the Asyncify Tax
Stop sacrificing runtime performance just to bridge the architectural gap between synchronous WebAssembly execution and the asynchronous web.
I remember the first time I tried to port a C++ physics engine to the web. I had this neat, synchronous loop that checked for user input, calculated collisions, and then—critically—fetched updated world state from a remote server. In a native environment, you just block the thread. On the web? I hit a brick wall. The browser’s main thread cannot block, and JavaScript’s fetch returns a Promise. I spent three days staring at a frozen browser tab before I realized that WebAssembly and the asynchronous nature of the web are, by default, fundamentally incompatible.
For years, the "fix" for this has been a tool called Asyncify. It works, but it feels like using a sledgehammer to hang a picture frame. It bloats your binary, slows down execution, and makes debugging a nightmare. Thankfully, there is a better way emerging: the JavaScript Promise Integration (JSPI) API.
The Friction Between Two Worlds
To understand why we need JSPI, you have to look at the architectural mismatch. WebAssembly is designed as a stack machine. When a Wasm function calls a function, it pushes a frame onto the stack. When that function returns, it pops it. It’s a clean, linear, and incredibly fast process.
JavaScript, however, is built around an event loop. When you perform an async operation like await fetch(), the engine doesn't "stop" the world. It registers a callback, clears the current execution context, and goes back to the event loop.
When a Wasm module calls a JS function that returns a Promise, Wasm has no native way to say "pause my execution, wait for this Promise to resolve, and then pick up exactly where I left off." It expects an immediate return value.
The Asyncify Tax: Why We’re Moving On
Before JSPI, Binaryen’s Asyncify was the only game in town. Asyncify works by rewriting your Wasm binary at the instruction level. When an async call is made, Asyncify:
1. Unwinds the entire Wasm call stack, saving every local variable and return address into a linear memory buffer.
2. Yields control back to the JavaScript event loop.
3. When the Promise resolves, it rewinds the stack, restoring every frame from memory back into the Wasm stack.
This is a technical marvel, but the "tax" is steep. Your .wasm file can grow by 50% or more because of the extra instrumentation. More importantly, every function call in your module becomes slightly slower because it has to check if it's currently in "unwinding" or "rewinding" mode. If you’re building a high-performance game or a real-time audio processor, this overhead is a dealbreaker.
JSPI: The Native Way to Suspend
The JavaScript Promise Integration (JSPI) proposal changes the game by moving the suspension logic from the Wasm code itself into the browser engine. Instead of manually unwinding the stack into memory, the engine can simply "suspend" the Wasm stack—much like how fibers or green threads work in languages like Go or Erlang.
The engine swaps out the stack, executes other JS tasks, and when the Promise is ready, it swaps the original Wasm stack back in. No binary bloat. No runtime instrumentation.
How to Use JSPI in Raw JavaScript
If you are working with a raw WebAssembly module (not through a high-level tool like Emscripten), you interact with JSPI through the WebAssembly.Function constructor. This is currently available in Chrome behind the --js-flags="--experimental-wasm-jspi" flag or via an Origin Trial.
Here is the pattern. Suppose we have a Wasm function that needs to call a JS function that fetches some data.
1. The Asynchronous JavaScript Function
// This is our standard async JS function
async function getRemoteData(id) {
const response = await fetch(`/api/data/${id}`);
const json = await response.json();
return json.value; // Let's assume this returns an integer
}2. Wrapping for Wasm
To make this function "awaitable" by Wasm, we have to wrap it using the WebAssembly.Function constructor with the suspending: 'always' property.
const suspendingGetRemoteData = new WebAssembly.Function(
{ parameters: ['i32'], results: ['i32'] },
getRemoteData,
{ suspending: 'always' }
);3. Importing into Wasm
When you instantiate your Wasm module, you pass this wrapped function in.
const imports = {
env: {
get_data: suspendingGetRemoteData
}
};
const { instance } = await WebAssembly.instantiate(wasmBytes, imports);4. The Wasm Side (Wat)
Inside your Wasm, the call looks completely synchronous. The Wasm engine handles the suspension magic under the hood.
(module
(import "env" "get_data" (func $get_data (param i32) (result i32)))
(func (export "process_remote_data") (param $id i32) (result i32)
;; This looks like a regular sync call
local.get $id
call $get_data
;; When control returns here, the Promise has resolved
i32.const 10
i32.add
)
)5. Calling from JS
Finally, because the Wasm function process_remote_data can now be suspended, it returns a Promise to JavaScript, even if it’s defined as returning an i32 in the Wasm.
const resultPromise = instance.exports.process_remote_data(42);
resultPromise.then(val => console.log("Final value:", val));Implementing JSPI with Emscripten
While the raw API is interesting, most of us use Emscripten to compile C++ or Rust to Wasm. Emscripten has added support for JSPI, making it significantly easier to port legacy codebases without the Asyncify performance hit.
Instead of passing -s ASYNCIFY=1, you now use:
emcc main.cpp -o index.html \
-s JSPI=1 \
-s ASYNCIFY_IMPORTS='["your_js_function_name"]'Wait—why are we still using a flag named ASYNCIFY_IMPORTS? In the Emscripten world, this flag tells the compiler which functions are expected to return a Promise. When JSPI=1 is enabled, Emscripten uses that list to generate the WebAssembly.Function wrappers we saw earlier instead of doing the heavy binary rewriting.
A Practical C++ Example
Imagine you have a C++ application that needs to save a file to IndexedDB. IndexedDB is inherently asynchronous.
The C++ Code:
#include <emscripten.h>
#include <iostream>
// Declare the JS function we will import
extern "C" {
extern int save_to_db(const char* data);
}
int main() {
std::cout << "Saving data..." << std::endl;
// This call will suspend the Wasm execution
int status = save_to_db("Some complex state string");
if (status == 1) {
std::cout << "Save successful!" << std::endl;
} else {
std::cout << "Save failed." << std::endl;
}
return 0;
}The JavaScript Library (`library.js`):
mergeInto(LibraryManager.library, {
save_to_db: async function(dataPtr) {
const data = UTF8ToString(dataPtr);
try {
// Simulate an async IndexedDB or Fetch operation
await new Promise(resolve => setTimeout(resolve, 1000));
console.log("JS: Data saved to DB:", data);
return 1;
} catch (e) {
return 0;
}
}
});The Compilation Command:
emcc main.cpp --js-library library.js -o test.html -s JSPI=1 -s ASYNCIFY_IMPORTS='["save_to_db"]'Why JSPI is a Performance Win
The performance difference isn't just theoretical. In my testing, applications utilizing JSPI show two major improvements:
1. Binary Size: Because the code isn't being instrumented with thousands of state-checks and stack-saving logic, the .wasm file remains lean. For a medium-sized project, this can mean a 200KB to 500KB reduction in transfer size.
2. Execution Speed: Asyncify adds overhead to *every* function call within the instrumented call path. JSPI has zero overhead during normal execution. The only "cost" is paid during the actual suspension, and even that is handled by the browser's optimized stack-switching logic.
The "Gotchas" and Edge Cases
It isn't all sunshine and rainbows. JSPI is still an evolving standard, and there are a few things that will trip you up:
1. The "Top-Level" Call Must be Promisified
Once a Wasm function suspends, it becomes an asynchronous operation. This means any JavaScript that calls a Wasm export which might suspend must treat the return value as a Promise. If your JS code expects a synchronous integer return, it will break.
2. Browser Support
As of late 2023/early 2024, JSPI is primarily a Chromium feature. It's in an Origin Trial, meaning you can use it in production for Chrome users if you register your domain, but Firefox and Safari are still in the "considering" or "implementing" phases. You will likely need an Asyncify fallback for other browsers for now.
3. Stack Limits
Since JSPI creates a new stack for the suspended Wasm execution, there is a memory cost. If you create thousands of suspended "fibers," you might run into memory pressure. However, for most use cases (sequential network requests, file I/O), this is negligible.
4. Reentrancy
What happens if you call a Wasm function, it suspends, and then—while it's suspended—you call another (or the same) Wasm function? This is "reentrancy," and it can lead to very confusing state bugs. You need to ensure your JS logic doesn't accidentally trigger the same Wasm process while a previous one is hanging in the air.
When Should You Stick with Asyncify?
Despite the "tax," Asyncify is still useful in one specific scenario: Legacy Browser Support.
If your target audience is using older versions of Safari or Firefox, JSPI simply won't work. However, the beauty of the current tooling is that you can often keep your source code identical and just swap your build flags. You could even ship two versions of your .wasm file and use feature detection to serve the JSPI version to Chrome users and the Asyncify version to others.
Final Thoughts
The gap between WebAssembly’s synchronous execution and the Web’s asynchronous reality has been one of the biggest friction points for developers porting native code. Asyncify was a clever hack that got us through the early years, but it’s time to move toward a more native solution.
JSPI represents a fundamental shift. It treats suspension as a first-class citizen of the runtime rather than a transformation of the binary. By offloading the burden of stack management to the browser engine, we get smaller binaries, faster execution, and a much cleaner development experience.
If you’re starting a new Wasm project today, or if you're struggling with the performance of an Asyncify-heavy build, give JSPI a look. The tax season for Wasm is finally coming to an end.


