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-futures and web-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.

Polled, not suspended The async function compiles to a state machine. The executor polls it, it returns pending while waiting on a promise, and the promise's resolution triggers another poll. Each poll is an ordinary synchronous call into the module. poll → Pending module returns promise.then(poll) registered by the executor promise settles event loop, later poll again — a fresh synchronous call into the module Between the polls the module is not running at all, which is why it holds no stack and cannot keep a borrow across an await boundary.

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.

The future owns what it keeps Between polls the module's stack is gone, so anything the future needs afterwards must live inside the state machine. Borrows and raw pointers taken before an await are not valid after it. before the await clone or move what is needed into the state machine the await module exits, other code runs memory may grow after the await re-derive pointers and views borrows are gone Rust's borrow checker enforces the first two; the pointer rule is on you, and it is the same rule that applies 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.

How the await actually happens The Rust future is not driven by Rust. The generated glue registers a waker as a promise callback, returns to the event loop, and resumes the future when the promise settles. async fn in Rust returns a future JsFuture::from wraps the promise event loop the stack unwinds waker resumes the future continues Between the second and third box the Rust call has fully returned — nothing of it is on the stack. That is why a borrow cannot be held across an await: the state machine has to own everything it resumes with. A rejected promise arrives as an Err carrying the JsValue, so it is handled like any other error.

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_local for 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.

← Back to Async & Event-Loop Integration