Using Async Rust Executors in the Browser
This page answers one task: you have async Rust code — or want to write some — and need it to run in the browser, where there is no Tokio runtime, no threads by default, and an event loop owned by JavaScript. You want to know what drives futures there and which async building blocks work.
Prerequisites
- [ ] A Rust crate built with wasm-bindgen for
wasm32-unknown-unknown. - [ ] Familiarity with Rust’s
async/.awaitand futures. - [ ]
wasm-bindgen-futuresadded as a dependency.
Who runs your futures in a page
A Rust future does nothing until an executor polls it. Natively, Tokio or another runtime provides the executor: threads that poll futures and a reactor that wakes them when sockets or timers are ready. In a browser page, the browser’s event loop already plays both roles — it runs tasks and microtasks and delivers I/O completions as promise resolutions — and Rust code must cooperate with it rather than replace it.
wasm-bindgen-futures is the bridge. spawn_local(future) queues a Rust future to be polled from the JavaScript microtask queue; when the future awaits
a JsFuture (a wrapped JavaScript Promise), the promise’s resolution wakes the Rust waker, which queues another poll. future_to_promise turns a Rust
future into a JavaScript Promise, and an async fn exported with #[wasm_bindgen] does the same automatically. Everything runs on the page’s main
thread (or the worker’s thread), interleaved with JavaScript, never in parallel with it.
Step 1 — spawn a future from Rust
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::{Request, Response};
#[wasm_bindgen]
pub fn start_sync() {
spawn_local(async {
match fetch_json("/api/config").await {
Ok(text) => web_sys::console::log_1(&text.into()),
Err(e) => web_sys::console::error_1(&e),
}
});
}
async fn fetch_json(url: &str) -> Result<String, JsValue> {
let window = web_sys::window().unwrap();
let resp: Response = JsFuture::from(window.fetch_with_str(url)).await?.dyn_into()?;
let text = JsFuture::from(resp.text()?).await?;
Ok(text.as_string().unwrap_or_default())
}
spawn_local returns immediately; the future runs as the event loop gets to it. Futures spawned this way must be 'static — they cannot borrow from the
calling function’s stack — and they need not be Send, because everything stays on one thread.
Step 2 — return a promise to JavaScript
When JavaScript should await the result, export an async function:
#[wasm_bindgen]
pub async fn load_config(url: String) -> Result<JsValue, JsValue> {
let text = fetch_json(&url).await?;
Ok(JsValue::from_str(&text))
}
const config = await load_config("/api/config");
wasm-bindgen wraps the future with future_to_promise. Errors returned as Err become promise rejections. Arguments must be owned (String, not &str)
because the future outlives the call.
Step 3 — use timers and channels that fit the event loop
Sleeping must yield to the event loop rather than block. gloo-timers provides futures backed by setTimeout, and futures channels work unchanged
because they only need a waker:
use futures::{channel::mpsc, StreamExt, SinkExt};
use gloo_timers::future::TimeoutFuture;
let (mut tx, mut rx) = mpsc::channel::<u32>(16);
spawn_local(async move {
for i in 0..3 {
TimeoutFuture::new(200).await;
tx.send(i).await.unwrap();
}
});
spawn_local(async move {
while let Some(v) = rx.next().await {
web_sys::console::log_1(&v.into());
}
});
Combinators from futures — join!, select!, FuturesUnordered — also work, giving concurrency (several operations in flight) without parallelism.
Step 4 — understand why Tokio’s runtime does not run here
Tokio’s multi-threaded runtime needs OS threads and a reactor based on epoll, kqueue or IOCP; none exist in a page. Tokio’s rt feature without
rt-multi-thread can compile for wasm32-unknown-unknown, along with sync primitives (Mutex, mpsc, oneshot), and some crates use those parts
successfully. But tokio::time and tokio::net need a driver the browser does not provide, and #[tokio::main] blocks the thread to run the runtime,
which the browser forbids. The practical rule: use tokio::sync types if a dependency needs them, and drive everything with wasm-bindgen-futures.
Step 5 — keep the same code building natively
For code that runs in both the browser and on a server, abstract the few runtime-specific operations — spawning, sleeping, HTTP — behind small functions
selected by cfg:
#[cfg(target_arch = "wasm32")]
pub fn spawn<F: Future<Output = ()> + 'static>(f: F) { wasm_bindgen_futures::spawn_local(f) }
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn<F: Future<Output = ()> + Send + 'static>(f: F) { tokio::spawn(f); }
#[cfg(target_arch = "wasm32")]
pub async fn sleep_ms(ms: u32) { gloo_timers::future::TimeoutFuture::new(ms).await }
#[cfg(not(target_arch = "wasm32"))]
pub async fn sleep_ms(ms: u32) { tokio::time::sleep(std::time::Duration::from_millis(ms as u64)).await }
The Send bound differs between targets; write shared async code so it is Send where possible, or keep native spawning local too
(tokio::task::spawn_local with a LocalSet). HTTP clients such as reqwest have a Wasm backend built on fetch, so much networking code needs no
abstraction at all.
Long computations inside async code
Async does not make CPU-bound work non-blocking. A future that runs a 300 ms computation between two .await points blocks the main thread for 300 ms,
exactly like a synchronous call. Either break the work into chunks with yields between them — awaiting a zero-delay timer, or better,
scheduler.yield() where available — or move it to a worker. Yielding frequently keeps input responsive at some throughput cost, as described in
keeping the UI responsive during long Wasm tasks.
Async in workers and with threads
In a dedicated worker, the same executor model applies — wasm-bindgen-futures polls on the worker’s event loop. With the atomics target feature and
shared memory, wasm-bindgen-futures can also wake futures across threads using Atomics.waitAsync, which allows futures to await events signalled by
other workers. Executors that run futures on a pool of workers exist, but they need cross-origin isolation and more setup; most applications do better
with one async event loop per worker and message passing between them.
Handling errors and panics in spawned futures
A future passed to spawn_local has nowhere to return an error: its output type is (). Errors must be handled inside the future — logged, reported to
an error tracker, or sent to the UI through a channel — or they disappear silently. A useful pattern is a small wrapper that every spawn goes through,
which awaits the inner future, and on Err logs the error with context and forwards it to the application’s error reporting. Panics are worse: a panic
inside a spawned future aborts the Wasm instance on wasm32-unknown-unknown (unwinding is not available by default), leaving every other pending future
dead and later calls into the module failing. Install console_error_panic_hook so the message is visible, avoid unwrap() on values that come from the
network or user input, and treat a panic as fatal for the instance, recreating it from JavaScript if the application must continue.
Ordering between Rust futures and JavaScript
Because futures are polled from the microtask queue, they interleave with JavaScript promise callbacks in a predictable but subtle order. A future
spawned during a click handler first runs after the handler returns and after earlier microtasks; code that expects the future to have started before the
next line of JavaScript runs will be wrong. When JavaScript needs to know that a Rust operation finished, return a promise and await it rather than
relying on timing. When Rust needs JavaScript state that might change between polls — the DOM, a global flag — read it after each .await, not once
at the start, since arbitrary JavaScript may have run in between.
Expected output
start_sync() logs the fetched configuration without blocking the page; await load_config(url) resolves in JavaScript; the timer-and-channel example
logs 0, 1 and 2 at 200 ms intervals; the shared crate builds natively with Tokio and for the browser with wasm-bindgen-futures; and a long computation
yields every 8 ms, keeping input latency under 50 ms.
Gotchas
#[tokio::main]in browser code. It blocks the thread. Usespawn_local.- Borrowing in spawned futures. They must be
'static. Move owned data in. &strparameters on exported async functions. Use owned types.- Expecting async to parallelise CPU work. It does not. Chunk and yield, or use workers.
tokio::time::sleepin the browser. No timer driver. Usegloo-timers.- Errors swallowed by
spawn_local. The future’s output is(). Route errors to logging or the UI inside the future.
Performance note
Polling a future through wasm-bindgen-futures adds roughly a microtask per wake-up — around 1–2 µs — which is negligible next to network or timer waits
but adds up for futures that wake thousands of times per frame. Batching work per wake-up keeps the overhead small.
Frequently Asked Questions
Can I use async-std in the browser?
Parts of it compile with a Wasm feature, but wasm-bindgen-futures is the standard driver.
Does reqwest work in the browser?
Yes — its Wasm backend uses fetch, with some features unavailable.
Can futures run in parallel with JavaScript? Not on the same thread; move work to a worker for parallelism.
How do I cancel a spawned future?
Use an abort signal or futures::future::Abortable; dropping the handle is not possible with spawn_local.
What happens if a spawned future panics? The instance aborts on the default target; every pending future dies. Avoid panics on external data and recreate the instance if one occurs.
Related
- Awaiting JavaScript promises from Rust —
JsFuturein depth. - Using std time and threads in Rust Wasm — what panics in the browser.
- Cancelling long-running Wasm work — cancellation patterns.
- Calling async JavaScript with JSPI — synchronous-looking async calls.
← Back to Async & Event-Loop Integration