Threads on the Web: What's actually involved
WebAssembly threads is less a single feature and more a stack of coordinated pieces that lets code written in C, C++, Rust, and similar languages use traditional multithreading on the web. It's a significant performance enabler: applications can split work across as many cores as the user's machine provides, cutting overall execution time substantially.
The Web Worker foundation
The base layer is the same Web Worker API JavaScript developers have used for over a decade. Each thread is spawned via the new Worker constructor, and each worker loads its own JavaScript glue. The main thread then hands off a compiled WebAssembly.Module and a shared WebAssembly.Memory using Worker#postMessage. All workers run the same WebAssembly code against the same memory, no JavaScript round-trips required for communication. Workers are widely supported and require no special flags.
SharedArrayBuffer and its eventful history
By default, WebAssembly.Memory wraps an ArrayBuffer, a buffer accessible by only one thread.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
The shared variant changes that model. Created via JavaScript API with the shared flag, or directly by the WebAssembly binary, it wraps a SharedArrayBuffer instead.
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
Unlike postMessage-based messaging, SharedArrayBuffer involves no data copying and doesn't wait for the event loop. Writes become visible to all threads almost immediately, which makes it a far better compilation target for classic synchronization primitives.
Getting SharedArrayBuffer back on the web took years. Several browsers shipped it in mid-2017, only to disable it in early 2018 after the Spectre vulnerabilities were disclosed. Spectre exploits rely on high-precision timing measurements, and shared memory combined with a counter loop in another thread is an astoundingly reliable timing source—far harder to mitigate than just reducing the precision of Date.now and performance.now.
Chrome 68 (mid-2018) re-enabled the feature behind Site Isolation, which places different sites in separate processes—a mitigation too expensive to enable by default on low-memory mobile devices. By 2020 both Chrome and Firefox had Site Isolation implementations and a standard opt-in path: COOP and COEP headers:
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
Opting in this way unlocks SharedArrayBuffer (which means WebAssembly.Memory can be backed by it), precise timers, and other APIs that require an isolated origin for security.
Atomics: keeping threads honest
Being able to read and write the same memory from multiple threads isn't enough on its own, as two threads can stomp on the same address simultaneously, producing corrupted reads. Synchronization is needed to prevent such race conditions, and atomic operations are where that happens.
Atomic instructions, an extension to the WebAssembly instruction set, read and write small data cells (mostly 32- and 64-bit integers) in a way that guarantees no conflicts at the hardware level. Two additional instruction kinds round out the set: wait and notify. A thread can sleep on an address in shared memory (wait) until another thread pokes it (notify). The entire zoo of higher-level primitives—channels, mutexes, read-write locks—is built directly on top of these instructions.
Threading in WebAssembly: build steps and pitfalls
Checking for thread support
WebAssembly atomics and SharedArrayBuffer are still not universally available in browsers that otherwise support WebAssembly. The webassembly.org roadmap tracks current support.
Because support varies, you'll need progressive enhancement: build two Wasm versions, one threaded and one not, then load appropriately at runtime. The wasm-feature-detect library lets you check for thread support cleanly:
import { threads } from 'wasm-feature-detect';
const hasThreads = await threads();
const module = await (
hasThreads
? import('./module-with-threads.js')
: import('./module-without-threads.js')
);
// …now use `module` as you normally would
Using threads from C
For C on Unix-like systems, threading typically means POSIX Threads via the pthread library. Emscripten provides a drop-in, API-compatible implementation of pthread that runs on top of Web Workers, shared memory, and atomics. Code written for native platforms can be compiled for the web unchanged.
Consider a simple C program:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *thread_callback(void *arg)
{
sleep(1);
printf("Inside the thread: %d\n", *(int *)arg);
return NULL;
}
int main()
{
puts("Before the thread");
pthread_t thread_id;
int arg = 42;
pthread_create(&thread_id, NULL, thread_callback, &arg);
pthread_join(thread_id, NULL);
puts("After the thread");
return 0;
}
Here, the pthread.h header provides the necessary types and functions. Two functions do the heavy lifting:
pthread_createspawns a background thread. It takes a handle destination, thread attributes (hereNULLfor defaults), a callback, and an optional argument pointer for data sharing from the main thread. In this example the argument shares a pointer to theargvariable.pthread_joinwaits for the thread to finish and can capture a return value. It accepts the thread handle frompthread_createand a result pointer;NULLworks when there's no result to retrieve.
Compiling with Emscripten follows the familiar pattern: invoke emcc and pass -pthread, just as you would with Clang or GCC natively.
emcc -pthread example.c -o example.js
But running the compiled module in a browser or Node.js produces a warning followed by a hang:
Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]
Why the deadlock happens
The root cause: web APIs that consume real time are asynchronous and depend on the event loop. That differs fundamentally from native environments, where blocking I/O is normal. (For background on the broader issue, see the discussion of using asynchronous web APIs from WebAssembly.)
In the example, pthread_create runs synchronously and requests a background thread, then pthread_join blocks the event loop while waiting for that thread. But the underlying Web Workers are created asynchronously. The pthread_create call only schedules Worker creation for the next event loop turn; pthread_join blocks before that turn ever arrives. The Worker never gets created, so the code waits forever — a classic deadlock.
A robust solution pre-creates the Worker pool before the main program runs. Then pthread_create can synchronously take a ready Worker from the pool, run the callback on it, and later return it to the pool. No deadlock occurs as long as the pool is large enough.
Emscripten's -s PTHREAD_POOL_SIZE=... flag implements exactly this behavior. The value can be a fixed number or a JavaScript expression like navigator.hardwareConcurrency to match the number of CPU cores. The latter suits code that can scale thread count dynamically.
For the simple example above, which spawns only one thread, a pool of size 1 suffices:
emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js
With the pool ready, execution succeeds:
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
The main-thread blocking problem remains
But there's another issue lurking in the code: the sleep(1) inside the thread callback runs on a background thread, which sounds fine — but it isn't. The main thread's pthread_join must wait for that call to finish before it can continue. If the background thread sleeps for one second, the main thread blocks for a full second too. In a browser, that blocks the UI thread for the same duration, hurting responsiveness.
Three patterns avoid this:
pthread_detachfor fire-and-forget work with no result needed.-s PROXY_TO_PTHREADfor whole C applications.- A custom Worker with Comlink for libraries that must expose async APIs.
Use pthread_detach when results aren't needed. This releases the thread to run in the background without the main thread waiting. The -s PTHREAD_POOL_SIZE_STRICT=0 flag suppresses the related warning.
Use -s PROXY_TO_PTHREAD for applications, not libraries. This shifts the entire main application code to a helper thread, alongside any nested threads the application spawns. The main thread never blocks the UI because blocking happens on the dedicated helper. An incidental bonus: with this flag you don't need a pre-created pool. Emscripten can use the main thread to spawn new Workers and then block only the helper thread in pthread_join, eliminating the deadlock.
For library code that must block, run it in your own Worker. Import the Emscripten-generated code there and expose it to the main thread via Comlink. The main thread sees exported methods as async functions, so the UI thread never freezes.
Given the earlier example is a small application, -s PROXY_TO_PTHREAD is the right fit:
emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js
C++ adds convenience, not new caveats
All of the above logic and constraints apply identically to C++. What changes is the available API. You get higher-level abstractions like std::thread and std::async, which internally wrap the same pthread machinery.
The earlier C example becomes more idiomatic C++:
#include <iostream>
#include <thread>
#include <chrono>
int main()
{
puts("Before the thread");
int arg = 42;
std::thread thread([&]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Inside the thread: " << arg << std::endl;
});
thread.join();
std::cout << "After the thread" << std::endl;
return 0;
}
With the same compilation flags, behavior stays consistent:
emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js
Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.
Rust takes a different path
Rust has no dedicated web tier like Emscripten. Its generic wasm32-unknown-unknown target produces plain Wasm, leaving all JavaScript interaction to external tooling such as wasm-bindgen and wasm-pack. Consequently, the standard library knows nothing about Web Workers, and standard APIs like std::thread simply don't work when compiled for Wasm.
The Rust ecosystem instead relies on higher-level crates that abstract platform differences. For data parallelism, Rayon is the dominant choice. It lets you convert sequential iterator chains into parallel ones, often with a one-line change:
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.iter()
.par_iter()
.map(|x| x * x)
.sum()
}
With that change, the input is split across threads, x * x and partial sums compute in parallel, and results combine at the end.
Rayon accounts for platforms without working std::thread by offering hooks for custom thread creation and teardown.
wasm-bindgen-rayon uses those hooks to spawn Wasm threads as Web Workers. Add it as a dependency and follow the setup steps in its docs. With the configuration done, the earlier example looks nearly unchanged:
pub use wasm_bindgen_rayon::init_thread_pool;
#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.par_iter()
.map(|x| x * x)
.sum()
}
The generated JavaScript additionally exports an initThreadPool function. This pool of Workers is reused for all Rayon operations during the program's lifetime.
As with Emscripten's -s PTHREAD_POOL_SIZE=..., initialize the pool before running the main code. Deadlock would otherwise occur:
import init, { initThreadPool, sum_of_squares } from './pkg/index.js';
// Regular wasm-bindgen initialization.
await init();
// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);
// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14
But threading hazards still apply. Even modest examples like sum_of_squares must block the main thread while waiting for partial results from other threads. That wait may be short or long, depending on the workload. Whatever the length, browser engines actively forbid blocking the main thread — such code throws an error. The safe pattern is the same as for C++ libraries: create a Worker, import the wasm-bindgen-generated code inside it, and expose its API through Comlink to the main thread.
The wasm-bindgen-rayon example project demonstrates the complete end-to-end flow:
- Feature-detecting threads
- Building both single- and multithreaded versions of the same Rust app
- Loading wasm-bindgen-generated JS+Wasm inside a Worker
- Initializing the thread pool via wasm-bindgen-rayon
- Using Comlink to expose the Worker's API to the main thread
WebAssembly threads in production
WebAssembly threads are not just a theoretical feature—they are already powering demanding applications in production. At Squoosh.app, an image compression tool, multithreaded codecs built with C++ (AVIF, JPEG-XL, WebP v2) and Rust (OxiPNG) deliver consistent 1.5x–3x speed-ups. Combining threads with WebAssembly SIMD pushes these numbers even higher.
Beyond Squoosh, Google Earth relies on WebAssembly threads for its web version, handling complex rendering workloads directly in the browser. FFMPEG.WASM, a compiled version of the FFmpeg multimedia framework, uses the same technology to encode video efficiently on the client side, without server round-trips.
These examples underscore a broader trend: developers are successfully porting existing multithreaded C, C++, and Rust applications to the web. The demos available across the ecosystem provide a practical starting point for anyone looking to bring their own performance-critical workloads to the browser.



