Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

wasm-bindgen-spawn

version badge license badge issues badge

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.x until all features required are in stable Rust, standardized in WASM, and baseline widely available across browsers.

Caution

Rust and wasm-bindgen frequently change the RUSTFLAGS needed 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-pack target 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

Support Matrix

Caution

WASM Threading is not standized. Rust and wasm-bindgen frequently change the RUSTFLAGS needed to compile with threading support enabled. If you cannot build with the example .cargo/config.toml in 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 tested 1.100.0-nightly)
    • Nightly toolchain is required
  • Wasm-bindgen: >=0.2.127
  • Wasm-pack: master

    Note

    The current latest stable version 0.15.0, has a version of binaryen over 2 years old. The master branch contains a new version required to parse some syntax in the DeadCodeElimination pass.

    You can install wasm-pack from GitHub directly with

    cargo 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 tested 24.19.0)
    • Earlier versions may work through the --experimental-wasm-exnref flag
  • Deno >=2.3.2 (latest tested 2.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 tested 1.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\EngineChromium1Firefox1Webkit1NodeJS2Deno3Bun2
no-modules
web
nodejs4,7
deno4,5
bundler4,5,6

Notes:

  • 1: Browsers use WebWorker API
  • 2: NodeJS and Bun use node:worker_threads API
  • 3: Deno uses Worker API because there were issues with node:worker_threads in the test harness setup. However this might change in the future if the test harness can workaround this issue.
  • 4: Targets other than no-modules and web are supported, but you have to also generate the bindgen script for either no-modules or web. See Setup Guide for more details.
  • 5: Additional setup is needed because bundler and deno target does not support the wasm_bindgen::module() API. See Setup Guide for more details.
  • 6: None of the engines can natively import the package generated by the bundler target. The test suite uses vite with the vite-plugin-wasm plugin.
  • 7: The nodejs target generates CommonJS and is not recommended if your project is modern and uses ESM. You either need to rename the generated script to .cjs extension or remove "type": "module" from package.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-bindgen frequently change the RUSTFLAGS needed to compile with threading support enabled. If you cannot build with the example .cargo/config.toml in this guide, please open an issue on GitHub.

Nightly rust is needed to use the unstable features we depend on.

There are 2 options:

  1. Add a rust-toolchain file to your crate or parent directories that indicate the version of the toolchain to use. For example:
    nightly
    
    Or specify a specific version
    nightly-2026-08-23
    
  2. 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=unwind is recommended for better experience working with panics in threads. However, there are some caveats. Please refer to the Panic Guide

The config below enables panic=unwind with comments for how to change to panic=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 (now js_sys::futures) to export async Rust functions that can be await-ed in JS. You can also use the API that returns Promise and return that to JS to be awaited to avoid depending on js-sys or wasm-bindgen-futures yourself.

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 nodejs target 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:

  1. A copy of the bindgen script for either the no-modules or web target like nodejs.
  2. 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:

  1. A copy of the bindgen script for either the no-modules or web target like nodejs and deno targets.
  2. The WASM module bytes, like deno.
  3. 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:

  1. Call one of the init_bg_* functions to create an instance of ThreadDispatcherInit.
  2. Use the JS Event Loop to wait for the thread dispatcher to be ready. The spawn() API only works after the thread dispatcher is await-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 the no-modules or the web target in wasm-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 than bundler and deno. 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_async takes move || 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_async API 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:

Async does not work with tokio runtime, diagram
  1. The JS worker spins up and invokes the thread’s main function.
  2. The thread enters an async runtime within Rust.
  3. With the async runtime, the fetch API in JS is called.
  4. The fetch API starts to do the network calls (implementation depends on the JS runtime/engine).
  5. The .await in Rust invokes the IntoFuture implementation of Promise, which is rather simple: it registers the waker of the future as the resolve and reject callbacks on the Promise object. 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_on implementation 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

Async does not work with spawn_local, diagram
  1. The JS worker spins up and invokes the thread’s main function.
  2. The thread spawns a JsFuture, which is backed by a Promise.
    • The implementation is as follows: The future will be poll-ed in Rust with a waker. If it returns Poll::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.
  3. JS Promises are eager, the promise is immediately scheduled onto the JS event loop to execute.
  4. Then the thread’s main function finishes
  5. The worker is terminated
  6. 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 the wasm-bindgen guide.

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_unwind has 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_unwind may 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 as PanicError.

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 in panic=unwind, the panic information is lost. The join handle gets a generic error thread panicked or aborted

Tip

You can observe the difference in behavior in the example_join_handle and example_mutex_poison examples in the Playground

Async panics

Note

Please refer to Working woth Async code for the spawn_async API

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:

async panic diagram
  1. We will start from calling do_something_async and ignoring everything happened before it for simplicity.
  2. do_something_async schedules some async work, returning a promise to Rust
  3. Rust awaits the promise by attaching the waker to the promise.
  4. When the async work is done, the JS event loop calls the then callback on the promise.
  5. The future wakes up; The js_sys::futures runtime polls the future
  6. Rust code panics and starts to unwind.
  7. The unwind reaches JS in the then callback. Since the callback does not wrap the Rust polling with try/catch, the exception reaches the JS runtime and triggers an Unhandled Rejection.
  8. 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 JsFuture implementation does have a .catch callback 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::futures inserts catch_unwind internally to catch panics in Rust, but it only creates a PanicError from it and throws it to JS. This is the right thing to do in the js_sys level, 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/catch and Rust catch_unwind.
    • if panic=unwind and 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.

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::spawn and dropping the JoinHandle.
  • In Tokio, calling tokio::task::spawn and dropping the JoinHandle.
  • In JS, spawning a future without await-ing or attaching .then/.catch callbacks, 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_panic example in the Playground when panic=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-06 after 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:

  1. Web Workers are “threads” in the browser environment.
  2. Instead of communicating with postMessage, we want to utilize the WebAssembly threads proposal to share memory between threads, using a shared WebAssembly.Memory object, which is backed by a SharedArrayBuffer.
  3. Rust toolchain already has (limited) support for synchronization primitives using the atomics feature:
Illustration Illustration: shared memory between worker threads

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 join is called
  • The worker will call notify when the thread is done.
Illustration Illustration: Idea 1

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.

Illustration Illustration: Idea 1, Problem 1 fixed

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:

  1. The spawning thread can only block after it knows the worker will execute the closure.
  2. 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:

  1. It requires spawn and join be async, which propagates and makes everything async in the Rust code1. This requires interop with JavaScript’s Promise (for example, using wasm-bindgen-futures), and makes the API more cumbersome and doesn’t feel like std::thread.
  2. Limitation of how Worker constructor and postMessage works 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.

Illustration Illustration: Creating the Dispatcher

Once the dispatcher is ready, the spawn and join flow is as follows:

  1. The spawning thread calls spawn with a closure and can immediately block
  2. The dispatcher receives a payload (which contains the closure and some channels)
  3. The dispatcher creates a new worker and wait for it to start using the JS event loop2
  4. Once the worker thread is running, the dispatcher can call recv and block again
  5. Once the worker is done, it notifies the JoinHandle in the spawning thread to unblock it.
Illustration Illustration: Spawning new workers with the Dispatcher

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_pooled example 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 the threadpool crate or the rayon crate.

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 navigator.hardwareConcurrency as the number of physical cores, whereas Chrome reports it as number of logical cores on CPUs with SMT/Hyperthreading. This appears to be fixed, at least on my machine.

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-mt project for the links they put in the README which sparked my interest to do a deep dive and ultimately create this project.
  • wasm-bindgen-rayon project, 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-bindgen and 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::mpsc async 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

  1. Removing the async feature as wasm-bindgen-futures crate has now been folded into js-sys and thus removed as an (optional) dependency of wasm-bindgen-spawn. All items that were previously available behind the async feature are now always available.
  2. Support for panic=unwind: join() now returns Result<T, Box<dyn Any + Send + 'static>>. When a thread panics, the panic payload can be observed by the JoinHandle just like the standard library.
  3. ThreadCreator struct 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.
  4. spawn now panics if the thread creation fails as it is unexpected and unlikely - just like the std library. Introduced try_spawn for a recoverable version.
  5. Expanded target support to other package formats supported by wasm-pack
  6. 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

  1. 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
  2. Remove the async feature from wasm-bindgen-spawn.

    • optionally, if you already depend on js-sys, remove wasm-bindgen-futures dependency and set --cfg=wasm_bindgen_use_js_sys to let wasm_bindgen use js-sys in the code generated by macros
  3. Remove ThreadCreator everywhere, replace the init site with the new APIs. See Creating the thread dispatcher

  4. Remove ? or .unwrap(), or any other error handling at each .spawn call site.

    • Or change spawn to try_spawn
  5. Update the error handling at each join call site. You can handle the error as you would using std::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"
          }
      }
      }