The threading model

WASM threads use a shared-memory model similar to pthreads. Multiple threads (browser Web Workers or WASI thread instances) share the same linear memory. Synchronization happens through atomic operations on that shared memory.

The key primitives:

  1. Shared linear memory, Declared with the shared flag, accessible by all threads
  2. Atomic operations, i32.atomic.load, i32.atomic.store, i32.atomic.rmw.add, i32.atomic.rmw.cmpxchg
  3. Wait/notify, memory.atomic.wait32 (block until memory changes), memory.atomic.notify (wake waiting threads)

Here's how you declare shared memory in WAT (WebAssembly Text format):

;; Shared memory: 256 pages (16MB) initial, 1024 pages (64MB) max
(memory (export "memory") 256 1024 shared)

From C with Emscripten:

// Compile with: emcc -pthread -s SHARED_MEMORY=1 -s PTHREAD_POOL_SIZE=navigator.hardwareConcurrency
#include <pthread.h>
#include <stdatomic.h>

#define WIDTH 800
#define HEIGHT 600
#define NUM_SAMPLES 64

// Shared framebuffer, visible to all threads
static uint32_t framebuffer[WIDTH * HEIGHT];
static atomic_int next_row;
static atomic_int completed_rows;

typedef struct {
 int thread_id;
 int total_threads;
} ThreadArgs;

void* render_thread(void* arg) {
 ThreadArgs* args = (ThreadArgs*)arg;

 while (1) {
 // Atomically claim the next row
 int row = atomic_fetch_add(&next_row, 1);
 if (row >= HEIGHT) break;

 for (int x = 0; x < WIDTH; x++) {
 // Monte Carlo ray tracing for this pixel
 float r = 0, g = 0, b = 0;
 for (int s = 0; s < NUM_SAMPLES; s++) {
 Ray ray = camera_ray(x, row, s);
 Vec3 color = trace(ray, scene, 0);
 r += color.x;
 g += color.y;
 b += color.z;
 }
 r /= NUM_SAMPLES;
 g /= NUM_SAMPLES;
 b /= NUM_SAMPLES;

 // Write directly to shared framebuffer
 framebuffer[row * WIDTH + x] = pack_rgba(r, g, b, 1.0f);
 }

 atomic_fetch_add(&completed_rows, 1);
 }

 return NULL;
}

int main() {
 int num_threads = 8; // Or detect from environment
 pthread_t threads[num_threads];
 ThreadArgs args[num_threads];

 atomic_store(&next_row, 0);
 atomic_store(&completed_rows, 0);

 for (int i = 0; i < num_threads; i++) {
 args[i] = (ThreadArgs){.thread_id = i, .total_threads = num_threads};
 pthread_create(&threads[i], NULL, render_thread, &args[i]);
 }

 for (int i = 0; i < num_threads; i++) {
 pthread_join(threads[i], NULL);
 }

 return 0;
}

Compile and run:

emcc ray_tracer.c -o ray_tracer.js \
 -pthread \
 -s SHARED_MEMORY=1 \
 -s PTHREAD_POOL_SIZE=8 \
 -s INITIAL_MEMORY=67108864 \
 -s MAXIMUM_MEMORY=268435456 \
 -s ALLOW_MEMORY_GROWTH=1 \
 -O3 -msimd128

Browser integration: Workers + SharedArrayBuffer

In the browser, each WASM thread runs in a Web Worker. The shared memory is a SharedArrayBuffer that all workers reference:

// main.js, spawns workers and shares memory
const memory = new WebAssembly.Memory({
 initial: 256,
 maximum: 1024,
 shared: true // This is the key flag
});

const numThreads = navigator.hardwareConcurrency || 4;
const workers = [];

// Load the WASM module
const response = await fetch('ray_tracer.wasm');
const wasmBytes = await response.arrayBuffer();

for (let i = 0; i < numThreads; i++) {
 const worker = new Worker('worker.js');
 worker.postMessage({
 type: 'init',
 wasmBytes,
 memory,
 threadId: i,
 totalThreads: numThreads
 });
 workers.push(worker);
}

// Read the framebuffer and render to canvas
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const imageData = ctx.createImageData(800, 600);

function renderFrame() {
 // Read directly from shared memory
 const pixels = new Uint8ClampedArray(memory.buffer, 0, 800 * 600 * 4);
 imageData.data.set(pixels);
 ctx.putImageData(imageData, 0, 0);
 requestAnimationFrame(renderFrame);
}

renderFrame();
// worker.js
self.onmessage = async (e) => {
 if (e.data.type === 'init') {
 const { wasmBytes, memory, threadId, totalThreads } = e.data;

 const imports = {
 env: {
 memory,
 __thread_id: threadId,
 __total_threads: totalThreads
 }
 };

 const { instance } = await WebAssembly.instantiate(wasmBytes, imports);
 instance.exports.render_thread();
 }
};

The critical requirement: your page must set security headers for SharedArrayBuffer to work:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Without these headers (introduced after the Spectre vulnerability), SharedArrayBuffer is disabled and your shared memory won't work. This is the most common deployment gotcha.

Performance: the real numbers

Benchmarking the ray tracer across configurations reveals telling results. The scene is a Cornell box with 4 spheres, 64 samples per pixel, 800x600 resolution:

Native C (gcc -O3, 8 threads): 1,240ms
WASM threads (Chrome, 8 workers): 1,890ms (1.52x native)
WASM threads (Firefox, 8 workers): 2,100ms (1.69x native)
WASM single-thread (Chrome): 9,450ms (7.62x native)
JavaScript (hand-optimized, 8 workers): 14,200ms (11.45x native)

WASM threads achieve 52-66% of native performance, a substantial improvement over single-threaded WASM (4-5x speedup from parallelism) and dramatically faster than JavaScript. The overhead versus native comes from:

  1. Bounds checking on shared memory access (~15% overhead)
  2. Worker creation cost (~50ms per worker, amortized over the render)
  3. No SIMD auto-vectorization across thread boundaries

The scaling is near-linear up to the physical core count:

1 thread: 9,450ms
2 threads: 4,780ms (1.98x speedup)
4 threads: 2,410ms (3.92x speedup)
8 threads: 1,890ms (5.00x speedup, 8 logical cores, 4 physical)

Hyperthreading gives diminishing returns, as expected for compute-bound workloads.

WASI threads outside the browser

The WASI threads proposal (wasi-threads) brings the same model to server-side WASM runtimes. Wasmtime and Wasmer both support it:

# Compile with wasi-threads target
clang --target=wasm32-wasi-threads \
 -pthread \
 -matomics -mbulk-memory \
 -Wl,--import-memory,--shared-memory,--max-memory=67108864 \
 -o ray_tracer.wasm ray_tracer.c

# Run with wasmtime
wasmtime run --wasi threads ray_tracer.wasm

In Rust, the wasm32-wasi-threads target is experimental but functional:

// Cargo.toml
[target.wasm32-wasi-threads]
rustflags = ["-C", "target-feature=+atomics,+bulk-memory"]

// src/main.rs
use std::sync::atomic::{AtomicI32, Ordering};
use std::sync::Arc;

fn main() {
 let counter = Arc::new(AtomicI32::new(0));
 let mut handles = vec![];

 for _ in 0..8 {
 let counter = Arc::clone(&counter);
 let handle = std::thread::spawn(move || {
 for _ in 0..1000 {
 counter.fetch_add(1, Ordering::Relaxed);
 }
 });
 handles.push(handle);
 }

 for handle in handles {
 handle.join().unwrap();
 }

 println!("Counter: {}", counter.load(Ordering::SeqCst));
 assert_eq!(counter.load(Ordering::SeqCst), 8000);
}
cargo build --target wasm32-wasi-threads --release
wasmtime run --wasi threads target/wasm32-wasi-threads/release/threaded_counter.wasm

Synchronization primitives

The atomic operations map directly to WASM instructions:

// Mutex implementation using WASM atomics
typedef struct {
 _Atomic int locked;
} WasmMutex;

void wasm_mutex_lock(WasmMutex* m) {
 while (1) {
 int expected = 0;
 if (atomic_compare_exchange_weak(&m->locked, &expected, 1)) {
 return; // Acquired
 }
 // Wait until the lock is released
 __builtin_wasm_memory_atomic_wait32(&m->locked, 1, -1);
 }
}

void wasm_mutex_unlock(WasmMutex* m) {
 atomic_store(&m->locked, 0);
 // Wake one waiting thread
 __builtin_wasm_memory_atomic_notify(&m->locked, 1);
}

The wait32/notify pair is crucial. Without it, you'd use a spin-lock that burns CPU cycles. wait32 tells the runtime "put this thread to sleep until the value at this memory address changes," which is essentially a futex.

Where this matters

WASM threads unlock workloads that were previously impractical in the browser or in sandboxed WASM environments:

  • Image/video processing: Parallel pixel operations, codec decoding
  • Scientific computing: Monte Carlo simulations, matrix operations
  • Game engines: Physics simulation, AI pathfinding on separate threads
  • Compression: Parallel block compression (zstd, brotli)
  • Machine learning inference: Parallel matrix multiplication for small models

A ray tracer serves as an effective proof of concept, but the pattern applies broadly. Any embarrassingly parallel workload, where you can divide work into independent chunks and aggregate results, benefits directly.

The remaining gaps: no thread-local storage in the WASM spec yet (workaround: manual offset into shared memory), no pthread_cond_t equivalent (workaround: build on wait32/notify), and the browser's worker pool has a creation overhead that makes short-lived threads expensive (workaround: keep workers alive and dispatch work to them).

WASM threads aren't a complete replacement for native threading. They're a good-enough approximation that brings real parallelism to sandboxed environments. For the workloads that benefit, and the ray tracer's 5x speedup from 8 threads proves they exist, that's enough to change what's possible.