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 set up 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 documentation on docs.rs

Special thanks

Support Matrix

Caution

WASM Threading is not standardized. 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 was last updated on: 2026-08-24

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 newer 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, 26.7.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 where 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 grow 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 the WebWorker API
  • 2: NodeJS and Bun use the node:worker_threads API
  • 3: Deno uses the 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 work around 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 the bundler and deno targets do 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 use the .cjs extension or remove "type": "module" from package.json, which may have additional consequences.

Rust std API support

If a std API is supported, it means you can use it with the threading model of this library and expect behavior similar to a tier-1 Rust target.

Tip

You can use the Playground to see the examples listed below in action. The playground also links you to the source of the examples.

Note

Legend:

  • ✅ Supported.
  • 🟢 Not supported but a similar API is provided by this crate (for example spawn).
  • ⚠️ Not tested but likely to be supported.
  • ❓ Not tested and unsure if supported.
  • ⛔ Unsupported.
APISupported?Example
std::sync::atomics::*1✅example_arc_atomic
std::sync::mpsc::*✅example_mpsc_channel
std::sync::Arc✅example_arc_atomic
std::sync::Barrier⚠️No tests yet
std::sync::Condvar⚠️No tests yet
std::sync::LazyLock⚠️No tests yet
std::sync::Mutex2✅example_mutex example_mutex_poison
std::sync::Once⚠️No tests yet
std::sync::OnceLock⚠️No tests yet
std::sync::RwLock⚠️No tests yet
std::sync::Weak⚠️No tests yet
std::thread::Builder⛔ 3
std::thread::JoinHandle🟢
std::thread::LocalKey and thread_local!✅No tests yet
std::thread::Thread::id() and ThreadId✅Used by harness
std::thread::Thread::name()❓No tests yet
std::thread::Thread::unpack()❓No tests yet
std::thread::available_parallelism⛔ 4example_available_parallelism
std::thread::current✅Used by harness
std::thread::panicking❓No tests yet
std::thread::park❓No tests yet
std::thread::scope⛔ 3
std::thread::sleep✅example_mutex example_mpsc_channel
std::thread::spawn🟢
std::thread::yield_now⛔ 5

Notes:

  • 1: Atomics are part of the WASM threading proposal.
  • 2: Poisoning works when panic=unwind.
  • 3: A similar API might be added to this crate in the future.
  • 4: Use navigator.hardwareConcurrency.
  • 5: Does nothing. To run the thread co-operatively with the JS event loop, use spawn_async.

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 responses from the web server that serves your project must send these headers to enable 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 standardized. 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 indicates the version of the toolchain to use. For example:
    nightly
    
    Or specify an exact 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 need changes. First, add a .cargo/config.toml file in the root of your crate

Note

Enabling panic=unwind is recommended for a 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 nightly 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 export async Rust functions that can be await-ed in JS. This feature requires additional dependencies. See API Usage for details.

no-modules and web

If you use the 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.unwrap();
    // 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 the nodejs target.
  2. Input to initSync in wasm_bindgen, which is the WASM module bytes (ArrayBuffer or JS Typed Array) or an instance of WebAssembly.Module.

    Tip

    The example below reads the bytes, but you can also create the Module yourself with, for example, the WebAssembly.compileStreaming API, if that is easier to integrate into your existing project or bundler setup.

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 bytes
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.unwrap();
    // 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 the nodejs and deno targets.
  2. The WASM module (ArrayBuffer, JS Typed Array, or WebAssembly.Module), for passing into initSync, like the deno target.

    Tip

    The example below reads the bytes, but you can also create the Module yourself with, for example, the WebAssembly.compileStreaming API, if that is easier to integrate into your existing project or bundler setup.

  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 with 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 the 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 have read through the required Setup Guide

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; this prevents dead locks from trying to join a thread before the 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 set up 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.

#![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, wasm_bindgen::module())
        .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)

Now the thread dispatcher is ready and you can spawn some threads!

Tip

You can use the async init function if you use wasm-bindgen-futures, or js_sys::futures (enabled with either the WASM_BINDGEN_USE_JS_SYS=1 environment variable or the --cfg=wasm_bindgen_use_js_sys cfg flag).

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, wasm_bindgen::module())
        .create_dispatcher_promise().into()
}
}

The JS side is identical

await wasm_bindgen.init_thread_dispatcher(bindgenScript)

Spawn and join

Spawn a thread with wasm_bindgen_spawn::spawn(), which has an 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);
}

Terminating the thread dispatcher

On native engines such as NodeJS, Deno and Bun, a worker that stays alive will keep the whole program running. Since the thread dispatcher is itself a worker, you may need to manually terminate it by calling terminate_dispatcher() during program shutdown.

#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn uninit() {
    wasm_bindgen_spawn::terminate_dispatcher();
}
}
// JS side, after you are done with all the threads
wasm_bindgen.uninit();

Handle async code

You can use wasm_bindgen_spawn::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 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 the Rust standard library, the wasm_bindgen_spawn::JoinHandle provides ways to perform a 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 an 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 still running
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 failures 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 a thread 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 the sections 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 response.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 in 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 omitted */ 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 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 wake up the async runtime in Rust.
  • We have a dead lock!

So blocking on an async runtime does not work, but what if we use the 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 omitted */ 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 when the thread’s main function returns?

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 an impl Future, but an 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 boundaries. 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 be sent to another thread to continue execution (even in 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 even more disastrous:

#![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 Send closure to capture it, which requires that 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 that triggers alternate control flow, similar to exceptions in other languages.

Abort vs. Unwind

Historically, when panic=unwind was not supported, panics triggered the 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 debuggability 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, inspect the payload, and optionally recover.
  • The WASM instance is not aborted
  • In wasm-bindgen, unwinds across the JS-Rust boundary manifest as a PanicError.

Note, however, that hard aborts can still happen even when panic=unwind, meaning this library needs to handle aborts if panic=abort 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 with 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 ignore everything that 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, an unhandled rejection will kill the worker directly, leading to a 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, a popular Rust async runtime. In tokio, the async runtime captures any async panic and reports it to the JoinHandle for the task.

This library does the same:

  • 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 lets the dispatcher notify 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 it, 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, instead of the js_sys one. This spawns the future wrapped with the hooks into the “worker runtime” 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 against threads hanging. Other unhandled rejections can still happen and there is not a one-size-fits-all way to deal with them. 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 an 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.

Contributing

Important

Please refer to mono-dev for contribution guidelines and tools setup for my projects.

You will need to set up tools for Rust and TypeScript. The instructions are in the link above.

You will also need Bun for building the JS code.

If you want to run the e2e tests, you need to install:

  • Deno to run tests for deno
  • docker to build the playwright container for browser tests

Repo Setup

After cloning the repo, cd to the repo and run the following commands:

pnpm install
task install
task icets
# ^ this installs additional tools directly on your system,
# please read Taskfile.yml before running

Building the library

Caution

The build-abort and build-unwind packages are meta files for building the example crate and the library itself using either panic=abort or panic=unwind.

Do not run cargo build or cargo build --target wasm32-unknown-unknown in either the example or lib packages, as those packages by themselves lack the cargo config to build.

cd to the test package and run:

task build

This will build the example crate using panic=abort and panic=unwind, as well as generating bundles for testing.

Run the tests

The test task accepts the following flags:

task run -- --<engine>... -E<test> TRIPLES... [-1]

--<engine>: specify the engines to run the tests on, for example --node only uses NodeJS
-E<test>: specify test filters, for example -Ejoin only runs tests with 'join' in the name
TRIPLES: specify triple filters, for example 'debug' only runs tests built with debug profile
-1: special flag to only run tests for the first triple after filtering the triples

For example, if you make a change, the minimal test procedure is:

task build
task run -- --node -1 abort
task run -- --node -1 unwind

This runs all test cases for both the abort and unwind panic strategies.

To test the browsers, you have 2 options:

  • Ensure you have docker installed and run a browser e2e test such as task run -- --chrome
  • Alternatively, build the playground website and manually run the examples:
    • Run task dev in the website package.

Checks

Run task check in the root of the repo to run all linters and formatters. Run task fix to automatically fix formatting issues.

Migration Guide for 0.0.7

Notable changes

  1. Removed the async feature, as the 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 now 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 standard 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 truth for the updated .cargo/config.toml. This is also the new location for the source-of-truth setup steps.

  2. Remove the async feature from wasm-bindgen-spawn.

    • Optionally, if you already depend on js-sys, remove the wasm-bindgen-futures dependency and set either the WASM_BINDGEN_USE_JS_SYS=1 environment variable or the --cfg=wasm_bindgen_use_js_sys cfg flag, 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: