wasm-bindgen-spawn
wasm-bindgen-spawn is a Web Worker based multithreading library for Rust and WebAssembly.
This uses the WebAssembly threads proposal
and shared memory to communicate between workers (once they are started), instead of postMessage.
The threads proposal is currently in phase 4 and available in all major browsers and runtimes.
Note
This library will remain on version
0.0.xuntil all features required are in stable Rust, standardized in WASM, and baseline widely available across browsers.
Caution
Rust and
wasm-bindgenfrequently change theRUSTFLAGSneeded to compile with threading support enabled. If you cannot build with configs given in this guide, please open an issue on GitHub.
Caution
Ensure you are using a supported version of the tools and engines. We are working at the very cutting edge. Most tools require the latest version or even building from source for unreleased bug fixes.
Getting Started
- See Support Matrix for the toolchains, JS engine, and
wasm-packtarget support. - See Setup Guide for how to setup your project to use this library.
- See Playground for runnable examples that also link to the example source code on GitHub.
- The remaining chapters of the book cover basic usage of the library. for advanced technical reference, see the API documention on docs.rs
Special thanks
wasm-mtproject for the links they put in the README which sparked my interest to do a deep dive and ultimately create this project.wasm-bindgen-rayonproject, which helped me understanding the prerequisites like Cross-Origin Isolation- Ciantic’s experimental work - very helpful in getting a basic example up and running
Support Matrix
Caution
WASM Threading is not standized. Rust and
wasm-bindgenfrequently change theRUSTFLAGSneeded to compile with threading support enabled. If you cannot build with the example.cargo/config.tomlin this guide, please open an issue on GitHub.See the source-of-truth for cargo configs Here
Note
This page is last updated on:
2026-08-23
Toolchain minimum versions
- Rust:
Nightly 2026-05-06(latest tested1.100.0-nightly)- Nightly toolchain is required
- Wasm-bindgen:
>=0.2.127 - Wasm-pack:
masterNote
The current latest stable version
0.15.0, has a version ofbinaryenover 2 years old. The master branch contains a new version required to parse some syntax in the DeadCodeElimination pass.You can install
wasm-packfrom GitHub directly withcargo install wasm-pack --git https://github.com/wasm-bindgen/wasm-pack --branch master
Engine minimum versions
The JS engine must support the Exception Handling with exnref and Threading WASM features. See WebAssembly Feature Status for the most up-to-date version matrix.
The test suite in this project runs all examples on Google Chrome, Microsoft Edge, Firefox, Webkit, NodeJS v24, NodeJS v26, Deno and Bun. You can use the Playground to run the examples with your current browser.
- Chromium
>=151.0 - Firefox
>=153.0 - Webkit
>=revision 2092 - NodeJS
>=24.15(latest tested24.19.0)- Earlier versions may work through the
--experimental-wasm-exnrefflag
- Earlier versions may work through the
- Deno
>=2.3.2(latest tested2.9.5)- *Note that Deno has an issue it takes ~5x longer to spin up a worker compared to other engines.
- Bun
>=1.3.14(latest tested1.4.0)- *Note that Bun has an issue where it sometimes segfaults when trying to glow WASM memory.
Warning
Even though technically the versions can be relaxed if you do not use
panic=unwind(meaning you do not need support for Exception Handling with exnref), the test suite only runs for the versions indicated above. Lower engine versions are not guaranteed to work since there can be subtle bugs or behavior differences in the APIs that we depend on.
wasm-pack Target Support
| Target\Engine | Chromium1 | Firefox1 | Webkit1 | NodeJS2 | Deno3 | Bun2 |
|---|---|---|---|---|---|---|
no-modules | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
web | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
nodejs4,7 | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ |
deno4,5 | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ |
bundler4,5,6 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
Notes:
- 1: Browsers use
WebWorkerAPI - 2: NodeJS and Bun use
node:worker_threadsAPI - 3: Deno uses
WorkerAPI because there were issues withnode:worker_threadsin the test harness setup. However this might change in the future if the test harness can workaround this issue. - 4: Targets other than
no-modulesandwebare supported, but you have to also generate the bindgen script for eitherno-modulesorweb. See Setup Guide for more details. - 5: Additional setup is needed because
bundleranddenotarget does not support thewasm_bindgen::module()API. See Setup Guide for more details. - 6: None of the engines can natively import the package generated by the
bundlertarget. The test suite usesvitewith thevite-plugin-wasmplugin. - 7: The
nodejstarget generates CommonJS and is not recommended if your project is modern and uses ESM. You either need to rename the generated script to.cjsextension or remove"type": "module"frompackage.json, which may have additional consequences.
Setup Guide
Important
Ensure you are using a supported version of the tools and engines.
See Support Matrix
Adding to Cargo dependency
Please add wasm-bindgen-spawn as a Cargo dependency to your Rust WASM project.
cargo add wasm-bindgen-spawn
Cross-Origin Isolation
You can read more about cross-origin isolation in this web.dev article. TL;DR is:
- This is required for
SharedArrayBuffer - This is to mitigate Spectre-like attacks
All frame and worker response from the web server that serves your project must send these headers to include cross-origin isolation:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
You can check that cross-origin isolation is enabled by running:
console.log(globalThis.crossOriginIsolated); // true
Note
This is not necessary for native engines such as NodeJS.
Caveat about blocking operations
Browsers do not allow the main thread to be blocked by Atomics.
Therefore, any blocking operations such as calling .join() on a thread’s join handle,
or .lock() on a Mutex, must be done in a Web Worker.
Native engines typically do not have this restriction, although blocking
the JS event loop may cause certain IO operations to pause,
such as console.log.
Rust and Cargo setup
Caution
WASM Threading is not standized. Rust and
wasm-bindgenfrequently change theRUSTFLAGSneeded to compile with threading support enabled. If you cannot build with the example.cargo/config.tomlin this guide, please open an issue on GitHub.
Nightly rust is needed to use the unstable features we depend on.
There are 2 options:
- Add a
rust-toolchainfile to your crate or parent directories that indicate the version of the toolchain to use. For example:
Or specify a specific versionnightlynightly-2026-08-23 - Specify the toolchain on every cargo invocation with
cargo +nightly
For cargo project setup, there are 2 places that needs change.
First add a .cargo/config.toml file in the root of your crate
Note
Enabling
panic=unwindis recommended for better experience working with panics in threads. However, there are some caveats. Please refer to the Panic GuideThe config below enables
panic=unwindwith comments for how to change topanic=abort
# This serves as the source of truth of what the .cargo/config.toml
# should look like
[target.wasm32-unknown-unknown]
rustflags = [
"-Ctarget-feature=+atomics",
"-Clink-args=--shared-memory",
"-Clink-args=--import-memory",
"-Clink-args=--max-memory=1073741824",
"-Clink-args=--export=__wasm_init_tls",
"-Clink-args=--export=__tls_size",
"-Clink-args=--export=__tls_align",
"-Clink-args=--export=__tls_base",
"-Clink-args=--export=__heap_base",
"-Cpanic=unwind" # -- remove if you use panic=abort, which is the default
# note: DO NOT pass --panic-unwind to wasm-pack because it will override
# other RUSTFLAGS needed for threading support
]
# RUSTFLAGS changelog:
#
# 2021-07-22 - Ciantic: Tested +simd128 22.7.2021, didn't work! Got some wasm-opt problems.
# 2024-10-01 - It now works, but threading works without it. So probably best to wait for it to stabilize.
# 2025-06-12 - mutable-globals is enabled by default, and bulk-memory is enabled by default on Rust 1.87+
# 2025-10-02 - rust now requires extra -Clink-args to enable shared-memory, see https://github.com/rust-lang/rust/pull/147225
# 2026-08-11 - Since WBG 0.2.122 / Rust nighty 2026-05-06, __heap_base needs to be explicitly exported.
[unstable]
build-std = ["panic_unwind", "std"] # -- change "panic_unwind" to "panic_abort" if you use panic=abort
[profile.release]
panic = "unwind" # -- remove this if you use panic=abort
Also add the following metadata for wasm-pack in Cargo.toml
[package]
# ... your package info
[dependencies]
# ... your dependencies info
[lib]
crate-type = ["cdylib", "rlib"] # -- this is required for wasm-bindgen/wasm-pack
# add these to use panic=unwind, remove if you wish to use panic=abort
[package.metadata.wasm-pack.profile.dev]
wasm-opt = ['--enable-exception-handling']
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['--enable-exception-handling', '-O']
[package.metadata.wasm-pack.profile.profiling]
wasm-opt = ['--enable-exception-handling', '-O']
Wasm-pack Target
Additional setup might be needed depending on the target (the -t/--target flag)
your project uses for wasm-pack build, which defaults to bundler.
See Support Matrix for the Target x JS Engine
support status.
Note
The examples use
wasm-bindgen-futures(nowjs_sys::futures) to export async Rust functions that can beawait-ed in JS. You can also use the API that returnsPromiseand return that to JS to be awaited to avoid depending onjs-sysorwasm-bindgen-futuresyourself.See API Docs
no-modules and web
If you use no-modules or web target, no additional setup is needed on the
wasm-pack side. Use wasm_bindgen_spawn::init_bg_no_modules or wasm_bindgen_spawn::init_bg_web
accordingly:
// JS side:
// need to fetch the bindgen script from your web service.
// (package_name would be whatever the crate name is or the `--out-name` parameter passed
// to wasm-pack)
const bindgenScriptLocation = location.origin + "/path/to/package_name.js";
const bindgenScript = await (await fetch(bindgenScriptLocation)).text();
// now initialize the wasm package
// here, assuming our target is web we can import the same path as an ESM.
// for no-modules, it will require extra build config, such as inlining the bindgen
// script into your code.
const wasm_bindgen = await import(bindgenScriptLocation);
// here we use the default-initialization which replaces the `.js` with `_bg.wasm`
// in the script path.
// if your wasm location is different you need to pass in { module_or_path: .. }
// to the init function
await wasm_bindgen.default();
// now we can initialize wasm-bindgen-spawn (see below)
await wasm_bindgen.init_thread_dispatcher(bindgenScript);
#![allow(unused)]
fn main() {
// Rust side:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub async fn init_thread_dispatcher(bg_script: JsValue) {
// call init_bg_no_modules if the script format is no-modules
wasm_bindgen_spawn::init_bg_web(bg_script, wasm_bindgen::module())
.create_dispatcher().await;
// once the future/promise returned by create_dispatcher/create_dispatcher_promise
// is resolved, you can start spawning threads.
}
}
nodejs
Warning
The
nodejstarget emits CommonJS which is not recommended in modern projects. You may have to change the file extension of the bindgen script to.cjs.
First, you need to generate a copy of the bindgen script for either the no-modules
or the web target. Here we use no-modules as an example.
# 1. run your normal build command
wasm-pack build -t nodejs ...
# 2. also build no-modules
wasm-pack build -t no-modules --out-dir some-temp-output ...
# 3. copy the bindgen script, the rest are not important
cp some-temp-output/my_package.js normal-output/my_package_no_modules.js
# 4. for this example you need to change the output extension to .cjs,
# your mileage may vary
mv normal-output/my_package.js normal-output/my_package.cjs
// JS Side, native engine (NodeJS or Bun)
import "fs" from "node:fs";
// the nodejs target script will auto-init the wasm module
const wasm_bindgen = await import("normal-output/my_package.cjs");
// we also need to read the no_modules script
const bindgenScript = fs.readFileSync("normal-output/my_package_no_modules.js", "utf8");
// now we can initialize wasm-bindgen-spawn
await wasm_bindgen.init_thread_dispatcher(bindgenScript);
See the no-modules/web section for the rust side
deno
The deno target requires:
- A copy of the bindgen script for either the
no-modulesorwebtarget likenodejs. - The WASM module bytes
Here we use no-modules as an example. See the nodejs section for the wasm-pack commands.
// JS Side, native engine (Deno or Bun)
import "fs" from "node:fs";
// the deno target script will auto-init the wasm module
const wasm_bindgen = await import("normal-output/my_package.js");
// we also need to read the no_modules script
const bindgenScript = fs.readFileSync("normal-output/my_package_no_modules.js", "utf8");
// we also need to read a copy of the wasm
const wasmBytes = fs.readFileSync("normal-output/my_package_bg.wasm");
// now we can initialize wasm-bindgen-spawn
await wasm_bindgen.init_thread_dispatcher_with_wasm(bindgenScript, wasmBytes);
#![allow(unused)]
fn main() {
// Rust side
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub async fn init_thread_dispatcher_with_wasm(bg_script: JsValue, wasm_bytes: JsValue) {
// call init_bg_web if the script format is web
wasm_bindgen_spawn::init_bg_no_modules(bg_script, wasm_bytes)
.create_dispatcher().await;
// once the future/promise returned by create_dispatcher/create_dispatcher_promise
// is resolved, you can start spawning threads.
}
}
bundler
The bundler target requires:
- A copy of the bindgen script for either the
no-modulesorwebtarget likenodejsanddenotargets. - The WASM module bytes, like
deno. - A bundler to bundle the code generated by wasm-pack.
See the nodejs section above for how to generate the additional bindgen script.
Using vite and vite-plugin-wasm as an example, a minimal config may look like
import { defineConfig } from "vite";
import wasm from "vite-plugin-wasm";
export default defineConfig({
plugins: [wasm(), /* ... other plugins */],
/* ... other configs */
});
Again using vite as an example, we may import the raw script using the ?raw parameter.
// the bundler must initialize the wasm instance
import wasm_bindgen from "my-wasm-pack-output";
import bindgenScript from "my-wasm-pack-output/my_package_no_modules.js?raw";
// currently there's no built-in way to import as binary, so we use the url method
// or you may use another plugin to load an asset as bytes
const wasmResponse = await fetch(new Url("my-wasm-pack-output/my_package_bg.wasm", import.meta.url));
const wasmBytes = await wasmResponse.arrayBuffer();
// now we can initialize wasm-bindgen-spawn
await wasm_bindgen.init_thread_dispatcher_with_wasm(bindgenScript, wasmBytes);
See deno section above for the Rust side.
API Usage
Important
Ensure you are using a supported version of the tools and engines as specified in the Support Matrix, and has read through the required Setups
This tutorial covers basic usage. For detailed technical reference please refer to the API Doc on docs.rs
Creating the thread dispatcher
The thread dispatcher is its own “thread” that allows new threads to be spawned without the need to rely on the JS event loop. You may read more about this design in the Design Blog
The thread dispatcher is initialized in 2 phases:
- Call one of the
init_bg_*functions to create an instance ofThreadDispatcherInit. - Use the JS Event Loop to wait for the thread dispatcher to be ready.
The
spawn()API only works after the thread dispatcher isawait-ed to be ready to prevent dead locks trying to join a thread before the thread dispatcher is ready.
#![allow(unused)]
fn main() {
// if you use wasm-pack build -t no-modules ...
let init = wasm_bindgen_spawn::init_bg_no_modules(bg_script, wasm_bindgen::module());
// if you use wasm-pack build -t web ...
let init = wasm_bindgen_spawn::init_bg_web(bg_script, wasm_bindgen::module());
}
Note
Currently the
init_bg_*function accepts the bindgen script from either theno-modulesor thewebtarget inwasm-pack. If your project uses another target, refer to Wasm-pack Target Setup. To setup the required scripts.The
wasm_bindgen::module()API is available in targets other thanbundleranddeno. Refer to the same setup guide for how to pass in the value from the JS side in these targets.
Next, create the dispatcher and wait for it to be ready.
If you use wasm-bindgen-futures, or js_sys::futures (with the WASM_BINDGEN_USE_JS_SYS=1 env flag or cfg flag),
you can use an async init function:
#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub async fn init_thread_dispatcher(bg_script: JsValue) {
wasm_bindgen_spawn::init_bg_no_modules(bg_script)
.create_dispatcher().await.unwrap();
}
}
Then call and await this function in JS (see Wasm-pack Target Setup for full setup)
await wasm_bindgen.init_thread_dispatcher(bindgenScript)
If you don’t want to add additional dependencies other than wasm-bindgen,
you can use the create_dispatcher_promise() API instead, which uses js_sys::futures
internally to drive the dispatcher future with the JS event loop.
#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn init_thread_dispatcher(bg_script: JsValue) -> JsValue /* Promise */{
// ^^ note this function is not async in rust
wasm_bindgen_spawn::init_bg_no_modules(bg_script)
.create_dispatcher_promise().into()
}
}
The JS side is identical
await wasm_bindgen.init_thread_dispatcher(bindgenScript)
Now the thread dispatcher is ready and you can spawn some threads!
Spawn and join
Spawn a thread with wasm_bindgen_spawn::spawn(), which has identical signature
to std::thread::spawn:
#![allow(unused)]
fn main() {
let thread = wasm_bindgen_spawn::spawn(move || {
/* here the code is now running inside a different worker JS context */
1
});
let output = thread.join().unwrap();
assert_eq!(output, 1);
}
Handle async code
You can use spawn_async to spawn a future as the “main function” of the thread.
The future is driven co-operatively with the JS event loop.
#![allow(unused)]
fn main() {
let thread = wasm_bindgen_spawn::spawn_async(move || async move {
// even though setTimeout does nothing, it is a minimal example
// that truly requires yielding to the JS event loop
let sleep = Function::new_no_args("return new Promise(r=>setTimeout(r,1000))");
let _ = sleep
.call0(&JsValue::undefined())
.unwrap()
.dyn_into::<Promise>()
.unwrap()
.await;
1
});
let output = thread.join().unwrap();
assert_eq!(output, 1);
}
Important
Before starting to use async threads, be sure to read read about the caveats, including why
spawn_asynctakesmove || async move {...}, in the Working with Async code chapter.
Non-blocking join
Similar to the JoinHandle in Rust standard library, the JoinHandle in this library
provides ways to perform non-blocking join of the thread.
#![allow(unused)]
fn main() {
let thread = wasm_bindgen_spawn::spawn(move || {
/* ... */
});
// use is_finished() to check if join will block
if thread.is_finished() {
// join will not block
let output = thread.join().unwrap();
}
}
The JoinHandle also implements IntoFuture for asynchronous join
#![allow(unused)]
fn main() {
let thread = wasm_bindgen_spawn::spawn(move || {
/* ... */
});
// asynchronously join the thread: the async runtime may do other things
// while the thread is not finished
let output = thread.await.unwrap();
}
Handle panic
The JoinHandle API captures panics in the thread and returns them as an Err
containing the opaque panic payload. The signature is identical to the standard library.
#![allow(unused)]
fn main() {
let thread = wasm_bindgen_spawn::spawn(move || {
/* ... */
});
match thread.join() {
Ok(x) => /* ... */,
Err(e /* Box<dyn Any + Send + 'static> */) => {
/* ... */
}
}
}
Caution
Panics are extremely tricky to handle, especially when different engines may exhibit different behaviors related to aborts and JS unhandled rejections, as well as panics in detached futures.
This library tries to make it really hard for panics to turn into uncontrollable failure if you do the right things. See the Working with Panic chapter for more information
Working with Async code
Important
This chapter covers caveats when spawning a future as threads using the
spawn_asyncAPI in this library. It is not a tutorial for async rust. Basic understanding of async rust is required.
Caution
Async code also requires extreme caution when dealing with panics and unhandled rejection!
Please refer to Async Panics after reading below
When an async thread is required
If we only look at the Rust code, it’s hard to imagine why the thread needs to be async. After all, the standard library does not have a method to spawn a future as a thread:
#![allow(unused)]
fn main() {
// spawn a thread
let thread = std::thread::spawn(move || {
// code here executes in a separate OS thread
// while sharing memory with other threads in the same process
// Want async? use an async runtime such as tokio!
tokio::runtime::LocalRuntime::new()
.unwrap().block_on(async move {
/* async rust code */
});
// when the thread is done, it's done for good - the OS
// may reclaim (destroy) the resources of this thread
});
}
However, if we zoom out to include the JS side, we will see some problems
with synchronous threads. Let’s brainstorm this together: To make the code less verbose, suppose we import
this function from the JS side with wasm_bindgen:
async function fetch_text(url) {
const response = await fetch(url);
const text = await url.text();
return text;
}
#![allow(unused)]
fn main() {
#[wasm_bindgen]
extern "C" {
fn fetch_text(url: &str): Promise;
}
let thread = wasm_bindgen_spawn::spawn(move || {
// code here executes in a separate worker context
// while sharing memory with other threads in the same process
// What if we need to wait for some async JS API?
fetch_text("https://github.com");
// what now?
// when the thread is done, it's done for good - the JS
// worker will be terminated
});
}
What if like standard rust, we involve an async runtime?
#![allow(unused)]
fn main() {
#[wasm_bindgen]
extern "C" {
fn fetch_text(url: &str): Promise;
}
let thread = wasm_bindgen_spawn::spawn(move || {
// code here executes in a separate worker context
// while sharing memory with other threads in the same process
// Want async? use an async runtime such as tokio!
let result = tokio::runtime::LocalRuntime::new()
.unwrap().block_on(async move {
let js_string = fetch_text("https://github.com").await.unwrap();
let rs_string: String = /* cast omited */ js_string;
rs_string
});
// when the thread is done, it's done for good - the JS
// worker will be terminated
});
}
This does not work! To see why let’s trace the process:
- The JS worker spins up and invokes the thread’s main function.
- The thread enters an async runtime within Rust.
- With the async runtime, the
fetchAPI in JS is called. - The
fetchAPI starts to do the network calls (implementation depends on the JS runtime/engine). - The
.awaitin Rust invokes theIntoFutureimplementation ofPromise, which is rather simple: it registers the waker of the future as theresolveandrejectcallbacks on thePromiseobject. When the promise is done, the waker notifies the runtime in Rust to poll the future again.
- Now comes the issue:
- For a promise to resolve, the control must be yielded back to the JS event loop. It cannot happen while the JS event loop is executing JS code. The JS event loop is in a context that invoked the thread’s main function, which must finish first before it can do anything else that’s scheduled
- However, the
block_onimplementation parks the thread until some future can be polled again (notified by the waker). It’s waiting for the JS event loop to resolve the future and wakes up the async runtime in Rust.
- We have a dead lock!
So block on an async runtime does not work, but what if we use js_sys::futures
runtime, which is backed by Promises and by-design driven co-operatively with
the JS event loop?
#![allow(unused)]
fn main() {
#[wasm_bindgen]
extern "C" {
fn fetch_text(url: &str): Promise;
}
let thread = wasm_bindgen_spawn::spawn(move || {
// code here executes in a separate worker context
// while sharing memory with other threads in the same process
// Want async? Maybe use js_sys::futures?
js_sys::futures::spawn_local(async move {
let js_string = fetch_text("https://github.com").await.unwrap();
let rs_string: String = /* cast omited */ js_string;
// wait.. how do we return the result?
});
// when the thread is done, it's done for good - the JS
// worker will be terminated
});
}
Well, this time, there’s no dead lock, but the future also does not execute at all. Let’s again trace the execution
- The JS worker spins up and invokes the thread’s main function.
- The thread spawns a
JsFuture, which is backed by aPromise.- The implementation is as follows: The future will be
poll-ed in Rust with a waker. If it returnsPoll::Pending, the future must store the waker so it can notify the runtime to poll the future again when ready (this is just normal async Rust stuff, not JS-specific). In this runtime, when the waker is notified, it will then schedule to poll the future again after yielding to the JS event loop.
- The implementation is as follows: The future will be
- JS Promises are eager, the promise is immediately scheduled onto the JS event loop to execute.
- Then the thread’s main function finishes
- The worker is terminated
- Scheduled futures never get to run before the worker dies!
Now we see the full picture. The only way around this is to make the thread’s main function async
to allow the JS event loop to do other things if the main function needs to await.
Note
Q: Wait! But you said “terminate the worker” when the thread is finished. What if we just don’t terminate the worker then the thread’s main function return?
A: Well, we still have to kill the thread when the main function finishes, otherwise the worker will just be left alive idling.
Q: Then we can register a callback so when the thread is finished, it can then terminate the worker…
A: Yes! And that’s exactly what we do!
invoke_thread_main().then(() => terminate_worker()); // is exactly the same as await invoke_thread_main(); terminate_worker()
Send bounds
You may have noticed that the spawn_async API does not take a impl Future,
but a impl FnOnce() -> impl Future. This is because the thread’s main function
and the thread’s future need to satisfy different trait bounds for the Send
trait.
If the spawn_async API requires impl Future + Send, it will be basically unusable
for what it’s meant to be used with:
#![allow(unused)]
fn main() {
wasm_bindgen_spawn::spawn_async(async move {
// hmm let's do something with JS
let js_value = get_value_from_js();
call_some_function_in_js(js_value).await;
// BOOM! ^ future is not Send!
})
}
Recall that the Send trait is a marker trait for a type to be safe to send
across thread boundary. Read more at Rust API Docs.
Now consider JsValue, a concrete example of a type that does not implement Send.
A JsValue is literally a reference to a value in the JS context. Obviously, you cannot reference the same value
in other JS contexts (i.e. other threads).
For a future to implement Send, it must be allowed to sent to another thread
to continue execution (even during the middle of the execution, at await points).
But in our case, the future is only ever spawned locally on the thread’s JS event loop,
so it actually does not require Send. However if we drop the Send requirement,
it will be more disasters:
#![allow(unused)]
fn main() {
// hmm let's do something with JS
let js_value = get_value_from_js();
wasm_bindgen_spawn::spawn_async(async move {
call_some_function_in_js(js_value).await;
// BOOM! ^ this is a reference that doesn't exist in this thread
})
}
As mentioned earlier, a JsValue is a reference to an object in the current
JS context. You cannot reference it in another JS context. So it’s not safe
to drop the Send requirement completely.
Therefore, we resort to
impl (FnOnce() -> impl Future) + Send
This means:
- The thread’s main function needs to be
Send - It will return a future to continue to do async work locally in the spawned thread.
This async work does not need to be
Send. - But if the future captures anything from the spawning thread, it also requires
the sync closure to capture it, which requires all captured variables be
Send.
Working with Panic
Important
This chapter covers caveats when a thread spawned with this library panics. It is not a tutorial about panicking in Rust, nor a tutorial about unwinding or
UnwindSafe-ty.This is also not a tutorial about how panics work in WASM or
wasm-bindgen. Please refer to Catching Panics and Handling Aborts in thewasm-bindgenguide.
In Rust, panic is a feature to trigger alternate control flow similar to exceptions in other languages.
Abort vs. Unwind
Historically when panic=unwind was not supported, panics triggers unreachable instruction
in WASM, which causes a dreaded Runtime Error: unreachable in JS:
// JS
try {
call_rust()
} catch(e) {
console.error(e);
}
#![allow(unused)]
fn main() {
// Rust
fn call_rust() {
std::panic::catch_unwind(|| {
panic!("wooo");
});
}
}
Output:
Runtime Error: unreachable
<some unreadable stack trace>
Historically, the debugability issue is solved by using a panic hook to print the panic information
before unreachable is triggered. However, it does not fix the fact that a panic hard-aborts the WASM instance, meaning:
- Variables are not dropped. Memory will leak.
- Mutexes are not poisoned. If a thread panics while holding a mutex, the mutex will never be released.
catch_unwindhas no effect.- The WASM instance is generally left in a state that’s not safe to call. If you call it anyway, it might be fine, or it might fail with disaster.
With panic=unwind however, things “just work”:
- Variables are dropped during unwind, cleaning up memory.
- Mutexes are also dropped during unwind, causing them to poison rather than lock forever.
catch_unwindmay be used to pause the unwind and inspect the payload, optionally recover.- The WASM instance is not aborted
- In
wasm-bindgen, unwinds across the JS-Rust boundary manifests asPanicError.
Note that however, hard-aborts can still happen even when panic=unwind, meaning this library
needs to handle aborts if panic=unwind and both aborts and unwinds if panic=unwind.
Panic from a synchronous thread
When a synchronous thread panics, the join handle will reliably detect the panic, even in the case
of panic=abort.
#![allow(unused)]
fn main() {
let thread = wasm_bindgen_spawn::spawn(move || {
panic!("test!");
}
assert!(thread.join().is_err());
}
The difference is the panic payload:
- If
panic=unwind, the original panic payload is delivered to the join handle, meaning you can inspect the error message, etc. - If
panic=abort, or the WASM instance hard-aborted inpanic=unwind, the panic information is lost. The join handle gets a generic errorthread panicked or aborted
Tip
You can observe the difference in behavior in the
example_join_handleandexample_mutex_poisonexamples in the Playground
Async panics
Note
Please refer to Working woth Async code for the
spawn_asyncAPI
When an asynchronous thread panics, it is trickier to deal with. To see why, let’s consider the following example:
#![allow(unused)]
fn main() {
#[wasm_bindgen]
extern "C" {
fn do_something_async() -> Promise;
}
wasm_bindgen_spawn::spawn_async(|| async {
let _ = do_something_async().await.unwrap();
panic!("test panic!");
});
}
This panic now cannot be simply handled by wrapping the thread with std::panic::catch_unwind.
Let’s trace the process to see exactly how this works:
- We will start from calling
do_something_asyncand ignoring everything happened before it for simplicity. do_something_asyncschedules some async work, returning a promise to Rust- Rust
awaits the promise by attaching the waker to the promise. - When the async work is done, the JS event loop calls the
thencallback on the promise. - The future wakes up; The
js_sys::futuresruntime polls the future - Rust code panics and starts to unwind.
- The unwind reaches JS in the
thencallback. Since the callback does not wrap the Rust polling withtry/catch, the exception reaches the JS runtime and triggers an Unhandled Rejection. - Since the polling never returned, the future is leaked, and the main thread’s future will never finish, leaving the worker and the thread hanging.
Note
In native JS runtimes, unhandled rejection will kill the worker directly, leading to memory leak in Rust and the thread hanging. In browsers, unhandled rejections are ignored.
Note
The
JsFutureimplementation does have a.catchcallback registered. However, it does not have a try-catch surrounding the code inside the callback itself. Think of it this way:do_something_async() .then(() => { wake_rust_future() // no try-catch here! // the catch below will not catch exceptions here }) .catch(() => /* ... */);In reality the async stack is a bit more complicated, but our point is already clear with this model.
Note
js_sys::futuresinsertscatch_unwindinternally to catch panics in Rust, but it only creates aPanicErrorfrom it and throws it to JS. This is the right thing to do in thejs_syslevel, but it does not help here.
While there is not a single “right” behavior for async panics, this library took inspiration from tokio,
whose runtime captures any async panic and reports it to the JoinHandle for the task.
This library does the same by:
- A thread-local “worker runtime” is installed to allow notifying the join handle and terminating the worker anywhere within Rust.
- The thread’s main future is double-wrapped with JS
try/catchand Rustcatch_unwind.- if
panic=unwindand a Rust unwind is caught, the panic payload is transmitted to the join handle and the worker is then terminated. - if a hard abort is caught by the JS
try/catch, Rust code is no longer safe to call, so the worker is terminated directly from JS code, without returning the control to Rust again. In this case, the worker notifies the thread dispatcher before termination and let the dispatcher notifies the join handle about the hard panic.
- if
With this, you can safely run this code and ensure the worker does not hang forever, in both panic=abort
and panic=unwind. In panic=unwind you will also get the panic message in the Err returned.
#![allow(unused)]
fn main() {
#[wasm_bindgen]
extern "C" {
fn do_something_async() -> Promise;
}
let handle = wasm_bindgen_spawn::spawn_async(|| async {
let _ = do_something_async().await.unwrap();
panic!("test panic!");
});
assert!(handle.join().is_err());
}
Async panics in detached tasks
A detached task/thread refers to a task that is still running, but cannot be joined,
for example due to the JoinHandle being dropped. You can do this in many frameworks,
for example:
- In standard Rust, calling
std::thread::spawnand dropping theJoinHandle. - In Tokio, calling
tokio::task::spawnand dropping theJoinHandle. - In JS, spawning a future without
await-ing or attaching.then/.catchcallbacks, or keeping a reference to that promise.
The last point is what we need to worry about here. You can spawn a Rust future
onto the JS event loop using js_sys::futures::spawn_local. This future will continue
to be polled, but js_sys does not provide a way to join the future, nor does it
wrap polling the future with try/catch. So spawning a future that panics using
this API will still result in the thread hanging.
Tip
You can experience this with the
example_async_panicexample in the Playground whenpanic=abort.
#![allow(unused)]
fn main() {
let handle = wasm_bindgen_spawn::spawn_async(|| async {
js_sys::futures::spawn_local(async move {
panic!("test panic!");
})
/* here we might need to keep the thread alive for longer until
the panic is triggered. for example await on a setTimeout */
});
}
What happens in this case depends on the runtime
- In native runtimes, the unhandled rejection causes the worker to terminate, so the thread will hang forever.
- In browsers, unhandled rejections are ignored, so the panic is ignored.
If other futures continue to execute Rust code when
panic=abort, it’s not safe and you may observe other weird errors/aborts
To mitigate this, you should use wasm_bindgen_spawn::spawn_local in worker threads.
This spawns the future wrapped with hooking into the “worker runtime” as described
above, and will reliably terminate the worker and notify the join handle when
panics are detected.
#![allow(unused)]
fn main() {
let handle = wasm_bindgen_spawn::spawn_async(|| async {
wasm_bindgen_spawn::spawn_local(async move {
panic!("test panic!"); // during the unwind/abort of this panic,
// the worker is terminated and the panic
// is transmitted to the join handle
})
/* here we might need to keep the thread alive for longer until
the panic is triggered. for example await on a setTimeout */
});
}
Warning
Note this is still not a bullet-proof vest to threads hanging. Other unhandled rejection can still happen and there is not a one-size-fit-all way to deal with it. For example:
#![allow(unused)] fn main() { wasm_bindgen_spawn::spawn_async(|| async { Function::new_no_args("void (async function() { throw new Error('hi') })()") .call0(&JsValue::undefined()); }); }The code above triggers a harmless unhandled rejection. In browsers, it’s ignored, in native runtimes, the worker is terminated and the thread hangs.
In the future this crate may install unhandled rejection handler or provide some utilities to run custom setup JS in the worker’s context to deal with these cases.
Design Blog
Important
Written on
2024-10-06after first version of this crate.This is not a tutorial for the crate - please see other chapters of this book, or checkout the Playground to this this crate in action.
Motivation
The dream is to be able to use std::thread::spawn in WebAssembly and things “just work”.
However, this is still far from working for the wasm32-unknown-unknown target.
Meanwhile, the underlying features required to implement threads in the browser environment are stable enough
that I want to look into implementing this myself.
Background
The backbone of the design is explained in “Multithreading Rust and Wasm”. Essentially:
- Web Workers are “threads” in the browser environment.
- Instead of communicating with
postMessage, we want to utilize the WebAssembly threads proposal to share memory between threads, using a sharedWebAssembly.Memoryobject, which is backed by aSharedArrayBuffer. - Rust toolchain already has (limited) support for synchronization primitives using the
atomicsfeature:
Starting Point
Since the API is to mimic std::thread::spawn, let’s first look at that:
#![allow(unused)]
fn main() {
// spawn a thread, returning a std::thread::JoinHandle for it
let handle = std::thread::spawn(|| {
println!("Hello from a thread!");
return 42;
});
// wait for thread to finish
let result = handle.join().unwrap();
assert_eq!(result, 42);
}
To model this pattern with Web Workers, we need to:
- On the main thread, create a web worker.
- Send the WASM module information to the worker, so it can instantiate the module.
- Send the shared memory object to the worker to allow it to access the shared memory.
- Send the closure as a raw pointer to the worker via
postMessage - The worker instantiates the WASM module and connects it to the shared memory.
- The worker will then call the closure and write the returned result to the shared memory.
- The main thread will wait with an atomic instruction when
joinis called - The worker will call
notifywhen the thread is done.
Problem 1: Main thread cannot block.
The web standard does not allow the main thread to block.
When the above is implemented, we get TypeError when trying to call join.
While this is inconvienient, it is not a big problem. The web page’s main thread needs to handle the UI updates, so we probably shouldn’t block it anyway. If multithreading is needed in the WASM module, it makes sense to first initialize it in a Web Worker and use it with Remote Procedure Call (RPC) pattern from the main thread with async/await.
Problem 2: Deadlock
After fixing the main thread blocking issue, we quickly observe that
a deadlock is created when calling join, and the worker is never started.
This is because in most browsers (tested in Chrome/Edge/Firefox), workers don’t start executing immediately after construction, but are queued up in the event loop. Therefore, we must wait until the worker starts executing the closure before we can start blocking.
This requires us to interface with the event loop with a Promise that resolves when the worker is ready, something like:
///// main thread
const promise = new Promise(resolve => {
const worker = new Worker('worker.js');
worker.onmessage = (e) => {
if (e.data === 1) {
resolve();
worker.postMessage(/*...*/)
}
};
});
promise.then(() => {
// start blocking
});
///// worker.js
importScript(/* wasm_bindgen output */);
self.onmessage = async (e) => {
const { /*...*/ } = e.data;
// initialize wasm module and shared memory
await wasm_bindgen(/*...*/);
// calling into wasm to execute the closure
await wasm_bindgen.__worker_main(/*...*/);
};
self.postMessage(1);
Problem 3: Deadlock (again)
As it turns out, it’s not just the Worker constructor that queues up
the execution in the event loop. postMessage also doesn’t make the other side
receive the message immediately. Essentially, we run into the dilemma:
- The spawning thread can only block after it knows the worker will execute the closure.
- When the worker knows it will execute the closure, it needs to let the spawning thread know, which is an async operation
The Issues
The solution above to problem 2 has 2 major problems that I don’t like:
- It requires
spawnandjoinbe async, which propagates and makes everything async in the Rust code1. This requires interop with JavaScript’s Promise (for example, usingwasm-bindgen-futures), and makes the API more cumbersome and doesn’t feel likestd::thread. - Limitation of how
Workerconstructor andpostMessageworks in the browser defeats multithreading entirely. If everything is properly synchronized, the threads can only run one at a time.
When I realized this, I stopped and went back to the drawing board to rethink the designl
And the solution? - Don’t use postMessage!
The Dispatcher
When the worker is created, we have to use postMessage to initiate the communication.
But once the WASM module is initialized, we can start using shared memory to communicate
the rest to the worker, which does not have the same restrictions with regards to the event loop.
So, I came up with the Dispatcher. It is a dedicated Web Worker that is just used to spawn threads. A one-time cost is paid to create the dispatcher and wait for it to be ready using the event loop.
Once the dispatcher is ready, the spawn and join flow is as follows:
- The spawning thread calls
spawnwith a closure and can immediately block - The dispatcher receives a payload (which contains the closure and some channels)
- The dispatcher creates a new worker and wait for it to start using the JS event loop2
- Once the worker thread is running, the dispatcher can call
recvand block again - Once the worker is done, it notifies the
JoinHandlein the spawning thread to unblock it.
This is the final design that I went with.
Other Limitations
Performance
Because each new thread (i.e. Worker) requires initializing the WASM module and
asynchronous communication via postMessage, it is VERY slow to spawn a new thread.
In my testing, it could take hundreds of milliseconds.
However, after the threads are up and running, sending messages between them is very fast using
channels. This is because we no longer rely on postMessage. The speed is dependent on
how the Web Workers are scheduled by the browser/runtime.
Fortunately, the same is true for threads on any platform and a solution already exist
- Reuse the threads with a pool. This is not implemented in this library, but it should be easy to do so. One of the examples shows how you can do it yourself.
Tip
Edit on 2026-08-24
Please see the
example_arc_atomic_pooledexample in the Playground for a basic concept of sending a large number of tasks to a small number of threads. Note that it is not a robust pool implementation like thethreadpoolcrate or therayoncrate.
Limit on Number of Workers
It is worth noting that Firefox limits the number of workers per domain to 20 by default, which could be lower than the the number of cores. The Dispatcher design allows extra workers to be queued up and started when the previous worker is done. However, if the limit is reached and all workers are blocked by something needed in an extra worker, a dead lock will happen.
Firefox also appears to report This appears
to be fixed, at least on my machine.navigator.hardwareConcurrency as the number of physical cores,
whereas Chrome reports it as number of logical cores on CPUs with SMT/Hyperthreading.
Panic, unwind, poison
Tip
Edit on 2026-08-24
As of
wasm-bindgen-spawn 0.0.7, unwinding is now supported. Please the setup guide. The info in this section is still true other than the fact that the exception handling proposal has been standardized.
Unwinding is Rust’s mechanism for recovering from panics. It’s not supported for wasm32-unknown-unknown target, so the panic behavior is abort. This means any panic will leave the WASM module in an inconsistent state and it should not be used again.
The implementation puts one thread per worker, and panicking/aborting from a thread will also terminate that worker. So it’s safe to panic.
However, because there’s no unwinding, mutex guards will not poison when the thread panics. Instead, the guard is not dropped and any subsequent access to the mutex will dead lock.
This can be improved when the exception handling proposal becomes stable and enabled by default in most browsers, from when we could catch the panic with unwinding and even send the panic payload back to the thread that called join.
Thanks
wasm-mtproject for the links they put in the README which sparked my interest to do a deep dive and ultimately create this project.wasm-bindgen-rayonproject, which helped me understanding the prerequisites like Cross-Origin Isolation- Ciantic’s experimental work - very helpful in getting a basic example up and running
- The
wasm-bindgenand related projects, and everyone else for the work put in to make Rust+WASM what it is today. - You - for reading this blog post.
- 1: Author’s note 2026-08-24: after implementing async threads in 0.0.7, it might have been a good idea to make everything async.
- 2: Author’s note 2026-08-24: When implementing 0.0.7 I realized
tokio::sync::mpscasync channels works with co-operative (async) receives, and is already needed so the dispatcher can handle worker panic termination while waiting for threads to be sent. So this part will get better when the next time I work on this project again.
Migration Guide for 0.0.7
Notable changes
- Removing the
asyncfeature aswasm-bindgen-futurescrate has now been folded intojs-sysand thus removed as an (optional) dependency ofwasm-bindgen-spawn. All items that were previously available behind theasyncfeature are now always available. - Support for
panic=unwind:join()now returnsResult<T, Box<dyn Any + Send + 'static>>. When a thread panics, the panic payload can be observed by theJoinHandlejust like the standard library. ThreadCreatorstruct is removed and the thread dispatcher instance is not available as a global singleton automatically managed within this crate. The crate exposes APIs to manage the dispatcher singleton.spawnnow panics if the thread creation fails as it is unexpected and unlikely - just like the std library. Introducedtry_spawnfor a recoverable version.- Expanded target support to other package formats supported by
wasm-pack - Expanded support to native runtimes: NodeJS/Deno/Bun
Other notable changes that are transparent to users:
- Proper TypeScript in the build for the JS dispatcher implementation, so there is type safety across the Rust and JS code.
- End-to-end test coverage
Migration Checklist
-
Refer to Cargo config Source of truch for the updated
.cargo/config.toml. This is also the new location for the source-of-truth setup steps.- It’s recommended you also update the configs to use
panic=unwind. Please first read the wasm-bindgen book chapters:- https://wasm-bindgen.github.io/wasm-bindgen/reference/catch-unwind.html
- https://wasm-bindgen.github.io/wasm-bindgen/reference/handling-aborts.html
- It’s recommended you also update the configs to use
-
Remove the
asyncfeature fromwasm-bindgen-spawn.- optionally, if you already depend on
js-sys, removewasm-bindgen-futuresdependency and set--cfg=wasm_bindgen_use_js_systo let wasm_bindgen usejs-sysin the code generated by macros
- optionally, if you already depend on
-
Remove
ThreadCreatoreverywhere, replace the init site with the new APIs. See Creating the thread dispatcher -
Remove
?or.unwrap(), or any other error handling at each.spawncall site.- Or change
spawntotry_spawn
- Or change
-
Update the error handling at each
joincall site. You can handle the error as you would usingstd::thread::JoinHandle.join(). Please refer to:- https://doc.rust-lang.org/std/thread/fn.spawn.html
- https://doc.rust-lang.org/std/thread/struct.JoinHandle.html#method.join
- if previously you were printing the error you can try this helper function:
#![allow(unused)] fn main() { pub fn best_effort_panic_message<'a>(payload: &'a Box<dyn Any + Send + 'static>) -> &'a str { if let Some(s) = payload.downcast_ref::<&str>() { s } else if let Some(s) = payload.downcast_ref::<String>() { s.as_str() } else { "unknown panic info" } } }