In the last part I built the kernel: a Rust function compiled to WASM that takes (seed, count), throws count darts at a quarter circle with a PCG32 generator, and returns two integers, inside and total. Deterministic, fast, and bit-identical on any honest machine.
But it runs on one thread. My laptop reports navigator.hardwareConcurrency = 16, which means fifteen sixteenths of the machine is sitting idle while one core does all the throwing. This part is about fixing that, what the fix actually bought me, and the three ceilings I hit that the "just use Web Workers" advice never mentions.
The trap I did not fall into
The naive version of "throw more darts" is a loop on the main thread. It is worth being explicit about why that is a trap, because it is the default thing you write.
The browser's main thread is where everything the user perceives happens: rendering, input handling, event dispatch. JavaScript on that thread runs to completion before the browser gets control back. A single work unit of a million darts finishes in a couple of milliseconds, which sounds harmless, but the moment you loop units back to back the page stops painting. Buttons do not respond. The tab gets the "page unresponsive" bar. You have built a page that computes pi and nothing else.
The usual patch is chunking: run one unit, setTimeout(next, 0), let the browser breathe. That un-freezes the page but caps you at one core and adds scheduling gaps between chunks. It treats the symptom.
The actual fix is Web Workers: separate threads with their own event loops, no DOM access, communicating with the main thread only through postMessage. The main thread goes back to doing main-thread things (rendering a dashboard, holding a WebSocket later in the series), and the compute happens somewhere the user cannot feel it.
One worker per core, one unit in flight
The design in pi-storm is a small pool. At startup I spawn one worker per logical core, capped at 16 so a very wide machine stays usable for its owner:
const MAX_WORKERS = 16;
const count = Math.min(navigator.hardwareConcurrency || 4, MAX_WORKERS);
for (let i = 0; i < count; i++) {
handles.push(spawnWorker(i)); // each worker instantiates its own WASM module
}
Each worker loads its own copy of the WASM module. That sounds wasteful until you look at what the module is: a few kilobytes of code and a linear memory that holds almost nothing, because the kernel streams darts through registers and returns two integers. Sixteen copies cost less than one decoded photo.
The worker side is almost embarrassingly short. The entire hot path lives inside WASM; JavaScript is just the doorman:
// worker.ts
addEventListener('message', async (event) => {
await ensureWasm(); // init once per worker, then reuse
const { seed, count } = event.data;
const out = run_pi_unit(BigInt(seed), count); // Uint32Array [inside, total]
postMessage({ inside: out[0], total: out[1] });
});
The main thread keeps a queue of work units and a rule: exactly one unit outstanding per worker at a time. A tiny pump drains the queue into whichever workers are idle, and every completed unit calls the pump again:
function pump(): void {
for (const handle of handles) {
if (handle.busy) continue;
const unit = unitQueue.shift();
if (!unit) break;
void compute(handle, unit);
}
}
async function compute(handle: WorkerHandle, unit: WorkUnit): Promise<void> {
handle.busy = true;
const res = await runUnit(handle, unit); // postMessage in, one message out
handle.busy = false;
if (res.ok) {
inside += res.output.inside; // aggregation is two integer adds
total += res.output.total;
}
pump();
}
That is the whole scheduler. No SharedArrayBuffer, no atomics, no cross-origin isolation headers. I want to dwell on that for a second, because it is the payoff of choosing an embarrassingly parallel problem: work units share nothing. Each one is (seed, count) in and two integers out, so the message-passing cost is a rounding error against a million darts of compute, and structured clone on a four-field object is nothing. The moment your units need to share state mid-flight, you enter the SharedArrayBuffer world with its COOP/COEP headers and memory-model reasoning. I did not need to, and if you can pick a decomposition that avoids it, do.
The "one unit in flight per worker" rule looks like a throughput limitation and is actually a feature. A unit is small, a few milliseconds of work. If a worker dies, or later in the series an entire tab closes, the most the system can lose is one unit per worker, and every unit is trivially re-runnable because the seed makes it deterministic. I did not design this for fault tolerance yet, but it is the seam fault tolerance will slide into two parts from now.
The receipt
With 16 workers saturated on my 16-thread machine, the pool sustains about 10.7 billion darts per second. That is the number I keep coming back to: a browser tab, no install, no build step for the visitor, doing ten billion deterministic operations a second on hardware I do not own.
Averaged out that is roughly 670 million darts per second per worker, which is close to what a single worker does alone. Scaling is nearly linear here, and it should be: the units are independent, the aggregation is two adds, and there is no lock anywhere. When your speedup is not linear on a problem like this, the problem is usually not your code. Which brings me to the ceilings.
The ceilings
hardwareConcurrency counts logical cores, not physical ones. My 16 is SMT threads on 8 physical cores. Two hyperthreads sharing a core do not give you two cores of throughput on a compute-dense kernel; they fight over the same execution units. The first 8 workers buy you a lot, the next 8 buy you noticeably less. The API cannot tell you the physical count, so the honest move is to measure your own scaling curve rather than assume 16 means 16.
Thermal throttling turns your benchmark into fiction. The number you measure in the first ten seconds is not the number you get in minute five. All-core sustained load on a laptop pushes the package to its thermal limit, and the firmware responds by dropping clocks. My peak throughput and my sustained throughput are different numbers, and any grid math I do later has to use the sustained one. If you benchmark parallel code, benchmark it warm.
Mobile is a different sport. A phone that reports 8 cores usually means a mix of performance and efficiency cores, so eight workers do not run at eight equal speeds; the pool naturally load-balances (fast cores just complete more units), but the aggregate is far below what the core count implies. Safari also deliberately clamps or fuzzes hardwareConcurrency as a fingerprinting defense, so you may not even get an honest count. And phones throttle harder and sooner than laptops. A phone is a real contributor to a grid like this, just a much smaller one than its spec sheet suggests.
There is a fourth ceiling worth naming even though it is self-imposed: the cap. Pinning every core on a visitor's machine is technically easy and socially wrong. The cap at 16 and, later in the series, an explicit opt-in before any worker spawns, are as much a part of the design as the pump loop.
Where this leaves things
One tab now uses one whole machine. The kernel is deterministic (part 2), the pool is saturating every core, and the aggregation is so cheap it is invisible. The obvious next thought is the whole premise of the series: if one tab can do 10.7 billion darts a second, what can fifty tabs on fifty machines do?
That question needs something new. The tabs cannot see each other. Somebody has to hand out (seed, count) units, collect the pairs of integers, and keep one global tally, which means somewhere in my serverless-first world there now has to be a server. Deciding where that coordinator lives turned out to be the most interesting infrastructure decision of the project, and it is the next part.
Previous: Estimating pi with Rust and WASM.