Awaiting JavaScript Promises from Rust
This guide answers one task: write Rust that awaits a JavaScript promise — a fetch, a timer, a worker
message — and understand what wasm-bindgen-futures is doing underneath, because the module is still
synchronous and something has to reconcile that.
Prerequisites
- [ ] A Rust crate with
wasm-bindgen,wasm-bindgen-futuresandweb-sys. - [ ] Familiarity with Rust’s
async/await. - [ ] An understanding that WebAssembly itself cannot suspend — see the topic overview.
- [ ] A browser to run it in; this does not apply to a WASI build.
What is actually happening
Rust’s async fn compiles to a state machine: a struct holding the function’s state and a poll method
that advances it. Nothing about that requires the language runtime to suspend a stack — the state machine
returns to its caller and is called again later.
That is why it works in WebAssembly. wasm-bindgen-futures provides an executor that lives on the
JavaScript side: it polls the state machine, and when the state machine is waiting on a promise, it
registers a then callback that polls it again when the promise settles.
So the module is entered, runs until the future returns Pending, and exits. Later, a promise resolves,
JavaScript calls back into the module, and the state machine advances. The module never suspends — it is
called repeatedly, which is a completely different mechanism with the same appearance.
Awaiting a promise
JsFuture wraps a JavaScript promise as a Rust future, which await then drives.
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, Response};
#[wasm_bindgen]
pub async fn fetch_json(url: String) -> Result<JsValue, JsValue> {
let opts = RequestInit::new();
opts.set_method("GET");
let request = Request::new_with_str_and_init(&url, &opts)?;
let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
let resp: Response = JsFuture::from(window.fetch_with_request(&request))
.await?
.dyn_into()?;
if !resp.ok() {
return Err(JsValue::from_str(&format!("HTTP {}", resp.status())));
}
JsFuture::from(resp.json()?).await
}
const data = await fetch_json('/api/config'); // an ordinary promise on the JavaScript side
An exported async fn becomes a JavaScript function returning a promise, so the caller sees something
entirely conventional. The ? operator propagates JavaScript errors as rejections, which is the same
mapping described in
propagating Rust Results to JavaScript.
Timers, events and other promise sources
fetch is the obvious promise, and several others come up often enough to be worth having to hand.
A delay needs a promise built on setTimeout, because there is no sleep:
use wasm_bindgen::closure::Closure;
use js_sys::Promise;
pub fn sleep(ms: i32) -> JsFuture {
let p = Promise::new(&mut |resolve, _reject| {
let win = web_sys::window().unwrap();
win.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms).unwrap();
});
JsFuture::from(p)
}
An event becomes a promise that resolves on the first occurrence, which suits waiting for a load or a user action:
pub fn once(target: &web_sys::EventTarget, event: &str) -> JsFuture {
let target = target.clone();
let event = event.to_string();
JsFuture::from(Promise::new(&mut move |resolve, _| {
let cb = Closure::once_into_js(move |_e: web_sys::Event| { resolve.call0(&JsValue::NULL).ok(); });
target.add_event_listener_with_callback(&event, cb.unchecked_ref()).unwrap();
}))
}
Closure::once_into_js matters here: an ordinary Closure must be kept alive by Rust for as long as
JavaScript may call it, and forgetting that produces a callback that fires into freed memory. The once
variant hands ownership to JavaScript, which is correct for a listener that fires at most once.
For anything that fires repeatedly — an interval, a stream of events — the closure must live as long as the subscription, which means storing it and dropping it when unsubscribing. That lifetime is the single most common source of bugs in this area and it has nothing to do with async as such.
Fire-and-forget with spawn_local
Sometimes the module needs to start asynchronous work without the caller awaiting it — a background refresh, a deferred write, a subscription.
use wasm_bindgen_futures::spawn_local;
#[wasm_bindgen]
pub fn start_background_refresh(url: String) {
spawn_local(async move {
match refresh(&url).await {
Ok(()) => web_sys::console::log_1(&"refreshed".into()),
Err(e) => web_sys::console::error_1(&e),
}
});
}
spawn_local hands the future to the executor and returns immediately. There is no join handle and no
cancellation, so a spawned task runs to completion or until the page goes away — which makes it suitable
for work that is genuinely fire-and-forget and unsuitable for anything a user might want to stop.
Handle the error inside the spawned future. A future that returns an Err nobody reads produces nothing at
all: no rejection, no console message, no report.
What you cannot hold across an await
Because the module exits between polls, a future cannot hold anything that assumes the stack persists. In practice the compiler enforces this, and the errors are worth recognising.
A borrow of module state cannot cross an await, because the state machine must own everything it keeps.
Clone or take ownership before the await point:
// will not compile: the borrow would have to live across the await
async fn bad(state: &Mutex<State>) -> Result<(), JsValue> {
let guard = state.lock().unwrap();
let data = fetch_data(&guard.url).await?; // guard held across await
Ok(())
}
// fine: take what is needed, drop the guard, then await
async fn good(state: &Mutex<State>) -> Result<(), JsValue> {
let url = { state.lock().unwrap().url.clone() };
let data = fetch_data(&url).await?;
Ok(())
}
A raw pointer into linear memory is likewise unsafe to hold across an await, because anything that runs
in between may have grown memory. Re-derive pointers after every await point, for exactly the reason views
must be rebuilt on the JavaScript side.
Structuring an async module interface
An exported async fn is convenient and it is not always the right shape, because it moves control of the
asynchrony into the module where the host may want it.
Two shapes work, and the choice is about who decides.
Module-driven: the export is async and does the awaiting itself. Simple for the caller, and the module
now needs browser capabilities — fetch, timers, the window — which ties it to the browser and makes it
harder to test and to reuse on a server.
Host-driven: the module exports synchronous functions and the host does the awaiting between them. More code on the host side, and the module stays a pure function that runs anywhere.
// host-driven: three synchronous exports the host sequences
#[wasm_bindgen] pub fn begin(url_ptr: *const u8, url_len: usize) -> i32 { … }
#[wasm_bindgen] pub fn feed(ptr: *const u8, len: usize) -> i32 { … }
#[wasm_bindgen] pub fn finish() -> i32 { … }
const res = await fetch(url);
const reader = res.body.getReader();
mod.exports.begin(urlPtr, urlLen);
for (;;) {
const { value, done } = await reader.read();
if (done) break;
feedIntoWasm(mod, value);
}
const out = mod.exports.finish();
The host-driven shape is more work and ages better. It keeps the capability question on the host side, it
makes the module testable without a browser, and it is the shape that ports unchanged to a worker, to Node
and to a server runtime — which is usually worth more than the convenience of an async fn.
Expected output
An exported async function behaves like any other promise-returning function:
const t0 = performance.now();
const cfg = await fetch_json('/api/config');
console.log(cfg, `${(performance.now() - t0).toFixed(1)} ms`);
// { region: 'eu-west-1', flags: {...} } 142.3 ms
// and a rejection
await fetch_json('/api/missing');
// Uncaught (in promise) HTTP 404
The elapsed time is almost entirely the network. The polling overhead — two or three additional calls into the module — is measured in microseconds and does not appear.
Gotchas
- Expecting the module to block. It does not; it returns and is called again.
- Holding a borrow across an await. The compiler rejects it; take ownership first.
- A raw pointer held across an await. Memory may have grown; re-derive it.
- Errors swallowed in
spawn_local. Nothing reads the future’s result; handle it inside. spawn_localfor cancellable work. There is no handle and no cancellation; use a flag the future checks.- Assuming this works under WASI. The executor depends on the JavaScript event loop; a standalone runtime needs a different one.
Performance note
Each await point costs one promise, one then registration and one additional entry into the module —
together roughly 5–20 microseconds. For a function awaiting a network request that is invisible. For a loop
awaiting something per item it is not: ten thousand awaits is 50–200 ms of pure overhead, which is the
reason to await a batch rather than an item wherever the shape allows it.
Frequently Asked Questions
Does this work in a worker? Yes. The executor uses whatever event loop it is on, and a worker has one. Only the browser APIs available differ.
Can I use tokio or async-std?
Not their runtimes, which assume threads and an operating system. The futures they define may work if they
are runtime-agnostic, but the executor must be wasm-bindgen-futures.
How do I add a timeout?
Race the future against a timer future built on setTimeout. There is no built-in timeout, and the loser of
the race keeps running unless it checks a flag — futures here are not cancelled by being dropped in the way
a native runtime might.
Related
- Calling async JavaScript with JSPI — the alternative for code that cannot be made async.
- Calling Web APIs from Rust with wasm-bindgen — the bindings this builds on.
- Streaming data into Wasm with ReadableStream — awaiting chunks rather than whole payloads.
← Back to Async & Event-Loop Integration