Daniel Studdard Gallups

Webble: A Stepping Stone for a Better Web

June 24, 2026

Don't we love webassembly?

I do.

But look. There's some real goodies you get to come across when shipping Rust to the browser. The browser is, at its heart, a single-threaded engine. Your beautiful, fast, native-feeling ECMAScript (JavaScript™) runs on the same cpu core that paints the page, handles your click and runs the analytics script that somebody added. If your code is busy, everything else waits...

But that's not to say that single-threaded sucker isn't fast. a TON of money was poured into the JavaScript™ V8 engine to make sure your cookie banner permissions run smoothly. But look: if you have more than one CPU core on your machine (which you do), shouldn't we be giving the other cores some love?

Multithreading breakdance, via Tenor

I had a bit of a problem with the single-threaded beast for an upcoming feature on Peacher, and in solving that problem, I built a multithreaded, work-stealing, async, rewritten-in-rust runtime called webble. While I finish cooking the peacher feature, I'd like to spend most of this post is me showing it off with real worker threads doing real work, live, on this page. But to explain why webble exists, I have to first explain why getting off that single dancing core has been such a long, strange road.

The main thread is sacred

A browser tab has one thread that matters: the main thread. It runs JavaScript™, it runs your WASM, it computes layout, it paints, and it dispatches every event. It does all of this by spinning an event loop: do a chunk of work, paint a frame, handle pending events, repeat. All of this work should happen ideally 60 times a second.

So, what happens if you decide to do a lot of work that the main thread can't get out of? You get jank everyone can feel. Click the jank button for an example:

watch the bar freeze mid-slide

If you sit in a tight loop for 200ms, the page can't paint and can't respond for 200ms. That's the jank everyone can feel. So the entire async model of the web, callbacks, promises, async/await, exists to let you give the thread back between bits of work instead of hogging it. (Audio devs know this pain all too well)

async/await is great for I/O, but is quite useless for actual computation. If you genuinely have a million things to add up, await doesn't make that faster, it just lets you feel bad about it more politely. For real parallelism you need real threads. And the web's relationship with threads is... complicated.

We've technically had threads for fifteen years

Web Workers shipped around 2009. A worker is a separate thread with its own JavaScript context, its own event loop, and crucially, its own memory. You talk to it by passing messages.

By default, a worker shares nothing with you. When you postMessage an object, the browser structured-clones it: it serializes a deep copy across the thread boundary. Two workers can't poke at the same array. It's more like they mail each other photocopies. For a lot of workloads, the copying costs more than the work you were trying to parallelize, and you certainly can't build a shared scheduler or a lock on top of photocopies.

What you actually want is shared memory: All the threads looking at the same photocopy in the same room. This implies a single buffer and real atomics. And the web did* get that (more on this later). It just took a security catastrophe and the better part of a decade to make it usable.

The SharedArrayBuffer saga

SharedArrayBuffer is the primitive that makes real threading possible. It is a chunk of memory that multiple threads can map at the same time, with Atomics and all that good stuff for safe concurrent access. Once this shipped, all was right in the world.

Except, not for long. In January 2018, Spectre happened. Spectre is a CPU-level side-channel attack, and the ingredient it craves is a high-resolution timer. To skip a lot of jargon, a SharedArrayBuffer plus a counter incrementing in another thread is a fantastic high-resolution timer. So within weeks, every major browser disabled SharedArrayBuffer. Not all was right in the world.

Eventually...it came back, on a leash. Around 2020, browsers re-enabled SharedArrayBuffer only for pages that opt into cross-origin isolation. You promise the browser, via two HTTP headers, that you aren't sharing a process with untrusted cross-origin content:

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

Send those, become crossOriginIsolated, and the browser hands SharedArrayBuffer back. (This very page sets them, which is the only reason the demos below run.) If you want the full story it's worth reading web.dev's COOP/COEP guide and the original Chromium intent-to-ship.

WebAssembly grew up

In parallel, WebAssembly was teaching itself to thread. The threads proposal adds a shared linear memory and a set of atomic instructions, including memory.atomic.wait and memory.atomic.notify, the building blocks for locks and condition variables right in the WASM. It reached phase 4, the last stop before becoming part of the core standard.

And then it actually arrived. In September 2025, the WebAssembly 3.0 spec was finished, folding threads and atomics (alongside 64-bit memory, garbage collection, SIMD, and exception handling) into the core language. YAY!

So now, we have shared memory, we have atomics, we have workers to run threads on. Every piece is on the table. It's good for it.

So...does it still hurt to use?

It is, genuinely, still painful. Setting this up introduces a few sharp edges:

  • The rust toolchain still fights you. Rust's standard library for WASM ships without thread support, so you need nightly and a -Z build-std rebuild of std with atomics enabled, plus a fistful of linker flags to import a shared memory. The wasm-bindgen-rayon project and the wasm-bindgen parallel raytracing example both come with a page of incantations before anything runs, and webble is no different.
  • The main thread can't wait. The natural way to make a thread sleep until there's work is Atomics.wait, but it's blocking, and blocking is forbidden on the main thread. The fix is Atomics.waitAsync, a non-blocking variant that returns a promise and is safe on the main thread (see the V8 writeup). It's the right tool, but now you get to hand-write a futex-on-a-promise.
  • Drawing off-thread is its own adventure. OffscreenCanvas lets a worker render, but you have to transfer canvas control across the thread boundary, support has historically been uneven, and coordinating who owns what gets fiddly fast.

Given all of this, it's not impossible, it just takes a lot of plumbing, and the plumbing is the same every time. That's exactly the kind of thing a runtime should own so you don't have to.

Enter webble

webble is a general-purpose async multithreaded runtime for the web. You run your Rust futures and closures across a pool of Web Workers that all share the WASM module's linear memory, real shared-memory threading, without blocking the main thread or the workers' event loops.

The core idea is that there's exactly one runtime per module, because the scheduler itself lives in shared linear memory. Every worker maps the same module and the same memory, so every worker sees the same scheduling state at the same address. Spawning a task is no longer a postMessage serialized payload copy. Now, it's a push onto a queue that every thread can see (and steal if the opportunity arises). It looks like this:

Main threadevent loop - DOM - on_main() work - never blocks (Atomics.waitAsync)Worker 0parks in waitAsyncWorker 1parks in waitAsyncWorker 2parks in waitAsyncWorker 3parks in waitAsyncShared linear memory (SharedArrayBuffer)one scheduler - per-worker slots - work-stealing deques - futex wordsevery worker sees the same state at the same address

You initialize it once on the main thread, then spawn work through free functions:

// once, on the main thread:
webble::builder().glue_path("/my_app.js").init()?;

// a closure, run to completion on a worker:
let mut sum = webble::spawn(|| (0..1_000_000u64).sum::<u64>());

// an async fn built a worker, so the returned Future can be !Send:
let greeting = webble::spawn(|| async {
    let local = std::rc::Rc::new("hello"); // !Send is fine here
    format!("{local} from a worker")
});

// CPU-heavy, load-balanced by work-stealing:
let handles: Vec<_> = (0..64)
    .map(|i| webble::spawn_stealable(async move { expensive(i) }))
    .collect();

There are two tracks:

  • Pinned (webble::spawn): placed on the least-loaded worker and owned by it forever. The future may be !Send, so you can hold an Rc or a JsValue across an await. This is often right default, as work-stealing has a decent amount of overhead.
  • Stealable (webble::spawn_stealable): enters a work-stealing deque and may migrate between workers on every wake. The future must be Send, but you get automatic load balancing for CPU-heavy workloads.

And because the main thread is a participant too, a worker can call webble::on_main(...) to run a closure back on the main thread (for DOM and other main-thread-only APIs) and await the result.

REAL threads doing REAL work

Alright, what I've been waiting to show you, some demos.

Here's the scheduler, live. Every bar below is a real worker thread in your browser. Hit inject and watch webble place a burst of tasks on the least-loaded workers, drain them, and park the workers again when the queue empties. The numbers are read straight out of the runtime each frame.

Workers: 0 Running: 0 Parked: 0 Queued (not yet running): 0

Spinning up the worker pool…

The parked workers in this demo aren't spinning around. They're sleeping on an Atomics.waitAsync, costing nothing until a futex word in shared memory gets bumped and wakes one of them. That's the non-blocking-event-loop promise: even a parked worker keeps its event loop alive, so timers and fetch inside your tasks stil make progress.

One of my favorite illustrations is the Mandelbrot set. I know, a bit on the nose for a multithreading demo, but it's quite a great stress test. Every pixel is an independent computation, and the pixels near the boundary are wildly more expensive than the ones far away. The image is chopped into tiles, and each tile is a webble task.

Render it first on a single thread, then on your whole pool. It's all the same pixels and math, but the only difference is how many workers chew through the tiles. Then, turn on tile tinting: each tile is colored by the worker that drew it. Click the image to zoom into the boundary.

Zoom: 1×
1 thread
N threads

Spinning up the worker pool…

Note: the speedup isn't a perfect Nx. You're bounded by your core count, by the tiles that finish fast leaving a worker idle, and by the overhead of coordination. But, it's real parallelism. It's off the main thread (the page stays responsive the entire render), and the tinting shows work-stealing doing its job: when one worker gets stuck on a dense tile, the others steal the easy ones and keep moving.

How it works under the hood

A quick tour of the machinery, because the details are the fun part:

  • The executor is async_task. Each future becomes a Runnable raw pointer. Scheduling a woken task is just routing that pointer to the right worker's queue in shared memory.
  • Wakeups are futexes. Every worker has a notify word. A producer bumps it and calls memory.atomic.notify; the worker, parked in Atomics.waitAsync, wakes and drains its queues on the microtask queue. There's a bitmask of parked workers so a producer can wake exactly one.
  • Shutdown is a Dekker handshake. Each worker flips a busy flag while it's touching the shared work-stealing deques, and shutdown waits for every worker to reach a safe point before terminating it, so the shared structures (which get reused across restarts) never get corrupted mid-operation.

The result is that "spawn a task" stays cheap no matter which thread you're on, and the runtime never has to block to coordinate.

I should be clear that almost none of this scheduler design is original to me. webble's bones, the per-worker slots, the work-stealing deques, the trick of capping the pool at 32 so the parked set fits in a single atomic, are lifted from Forte, nthtensor's low-overhead parallel and async work scheduler for native Rust. Forte's lazy heartbeat scheduling and its per-seat structure were the thing that convinced me a runtime like this could be both tiny and cheap, and chunks of webble are pretty much a port of its ideas across the wasm-and-workers boundary into the browser. If you do any parallel work in native Rust, go check it out. It's lovely.

Try it yourself

The setup is still a little fiddly (that's the web for you), but it's bounded. You need:

Nightly rust and a .cargo/config.toml that rebuilds std with atomics:

[target.wasm32-unknown-unknown]
rustflags = [
  "-Ctarget-feature=+atomics,+bulk-memory,+mutable-globals",
  "-Clink-arg=--shared-memory",
  "-Clink-arg=--import-memory",
  "-Clink-arg=--max-memory=1073741824",
  "-Clink-arg=--export=__wasm_init_tls",
  "-Clink-arg=--export=__tls_size",
  "-Clink-arg=--export=__tls_align",
  "-Clink-arg=--export=__tls_base",
  "-Clink-arg=--export=__heap_base",
]

[unstable]
build-std = ["std", "panic_abort"]

A --target web wasm-bindgen build (the workers re-import your glue and re-initialize against the shared memory), the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers from earlier, and then one call to webble::builder().init().

webble is on crates.io and the source is on GitHub. It's early, the API is still settling, and I'd genuinely love help. If you get stuck wiring it up, my Discord handle is dsgallups.

The web has had real threads sitting in the toolbox for a while now. They were, and still are, buried under a decade of security history and a pile of build flags. And while I can't immediately fix the above annoyances, webble is my attempt to hand you a tool. Let me know what you think. To a more performant web!