# Should my website be WebAssembly? One loop was enough

By Lucas Espindola · August 15, 2026 · https://lucasespindola.com/notes/one-loop-in-webassembly

> I measured before rewriting anything: the page stays JavaScript, but a 2.9 KB Rust kernel for matrix × vector runs 13–19× faster than the JS loop it replaces and makes the language models running in this site's browser apps 3–5× faster end to end, with the same results. Benchmarks, the memory rules that took most of the work, and what WebAssembly can't fix.

WebAssembly · SIMD · performance · side project

Should this website be a WebAssembly app? I measured instead of guessing. The answer is no for the page and yes for one loop: a 2.9 KB WebAssembly kernel for matrix × vector runs 13 to 19 times faster than the JavaScript it replaces, and makes the language models running in this site's browser apps 3 to 5 times faster end to end, with the same results.

The apps in question are [Neuron Dissector](https://lucasespindola.com/dissector) and [Prism](https://lucasespindola.com/prism), which open up a small story-writing model in your browser: Dissector lets you cut and boost its neurons while it writes, and Prism trains a sparse autoencoder on its activations and lets you steer the features it finds. Both run on a GPT-Neo and GPT-2 forward pass I wrote in JavaScript, because off-the-shelf runtimes don't let you rewrite a neuron in the middle of a run. Everything else on the site, the windows, the terminal, the Finder, is ordinary DOM code.

## Where the time goes

A CPU profile of Prism growing an atlas answered most of the question before any rewrite. The page itself never showed up: rendering stays smooth, because the models run in Web Workers. Nearly all the time was the forward pass and the autoencoder's encoder, and nearly all of that was one function: `matvec`, an output vector computed as a matrix times a vector. A transformer token is a dozen of them per layer; encoding one token into 2,048 features is one more, 2,048 rows long.

The plain JavaScript loop managed about 0.8 billion multiply-adds a second on a laptop. That's the number to beat.

Two things the profile also showed, which no rewrite would touch. Opening Prism's ready-made atlas the first time takes about 33 seconds, and most of that is downloading the 67 MB model from Hugging Face. And the user interface is DOM work: WebAssembly can't touch the DOM except through JavaScript, so moving the page to Rust and a WebAssembly UI framework would add glue code and a runtime download to a part that isn't slow.

## The kernel

The kernel is 90 lines of `no_std` Rust compiled to `wasm32-unknown-unknown` with 128-bit SIMD: four floats per instruction. It takes four rows of the matrix at a time, so each load of the input vector feeds four rows, and keeps two accumulators per row, eight columns a step, so the additions don't wait on each other:

```
while c < c8 {
    let xa = ld(x.add(c));
    let xb = ld(x.add(c + 4));
    a0 = f32x4_add(a0, f32x4_mul(ld(w0.add(c)), xa));
    b0 = f32x4_add(b0, f32x4_mul(ld(w0.add(c + 4)), xb));
    // … the same for rows 1, 2 and 3
    c += 8;
}
```

The built module is 2,892 bytes. I timed it against the JavaScript loop at the exact shapes the two apps multiply, each call timed over about 150 ms, best of five, in Node 22:

| Shape | Rows × columns | JavaScript | WebAssembly | Speed-up |
| --- | --- | --- | --- | --- |
| Attention projection, TinyStories-3M | 128 × 128 | 14.1 µs | 1.10 µs | 12.8× |
| MLP, TinyStories-3M | 512 × 128 | 81.6 µs | 4.46 µs | 18.3× |
| Prism's encoder, 2,048 features | 2,048 × 128 | 326 µs | 17.1 µs | 19.1× |
| Encoder, TinyStories-8M, 4,096 features | 4,096 × 256 | 1,285 µs | 69.1 µs | 18.6× |
| Output layer, 50,257-token vocabulary | 50,257 × 128 | 8.0 ms | 0.44 ms | 18.1× |

That's about 15 billion multiply-adds a second. In Chrome 150 the gap was 8 to 16 times, at 13.5 to 15.9 billion. The results match the JavaScript loop to within 1e-6: the same sums, added in a different order.

A gap that size made me suspicious of the baseline, so I gave JavaScript the same help: the four-row blocking and the paired accumulators, written by hand. It doubled, to 1.55 billion a second. WebAssembly was still ten times faster. The difference is what JavaScript can't express: four multiplies in one instruction, and array reads without bounds checks.

## End to end

A kernel benchmark is the best case. To find out what visitors get, I ran Prism's whole pipeline with the kernel on and off: read 8,000 tokens of TinyStories through the first four layers, train a 1,024-feature TopK autoencoder for three epochs, describe every feature, read off what each one predicts, and draw the map.

| Phase | JavaScript | WebAssembly | Speed-up |
| --- | --- | --- | --- |
| Read the stories | 10.1 s | 2.7 s | 3.7× |
| Train the autoencoder | 5.5 s | 1.2 s | 4.8× |
| Describe the features | 0.79 s | 0.15 s | 5.3× |
| Read what they predict | 0.22 s | 0.04 s | 5.5× |
| Draw the map | 0.72 s | 0.63 s | 1.1× |
| **Total** | **17.8 s** | **4.9 s** | **3.6×** |

The atlas came out the same: 32.10% of the variance unexplained with JavaScript, 32.12% with WebAssembly, all 1,024 features alive in both, and identical figures for how often a feature's top contexts are one word.

The end-to-end gain is a quarter of the kernel's because everything that isn't `matvec` is still JavaScript: attention over the previous tokens, the GELU activation, the optimizer's updates, the map's layout (a graph relaxation with no matrix in it, hence its 1.1×). That's Amdahl's law, and it says where to look next.

In the browser, growing an atlas for TinyStories-3M (24,000 tokens read on four worker threads, 1,024 features) took 48.6 seconds with the kernel disabled and 11.5 to 12.0 seconds with it, in the same session. Prism writing 74 tokens went from 1.9 to 0.6 seconds, and part of what's left is the worker yielding between tokens on purpose, so a newer request can cut in. Neuron Dissector writes the same 74 tokens in 0.6 seconds instead of 1.1.

## What it took

The kernel was the easy part. Most of the work was memory, because WebAssembly can only read its own.

### The matrix has to live there

Copying a weight matrix into WebAssembly memory for each call would cost more than the multiply. So the engine allocates from WebAssembly memory instead: a helper, `walloc`, hands out a `Float32Array` that's a view on it, and loading a model moves every weight there once. `matvec` checks where its matrix lives: if it's in WebAssembly memory, the kernel runs; if not, the JavaScript loop does. Input and output vectors that live elsewhere are copied through small staging buffers, which is cheap next to the matrix.

The same check is the fallback. Without WebAssembly SIMD, or once the memory is full, `walloc` returns an ordinary array, and everything still works, slower. I tested that path in the browser by serving a broken kernel: the same atlas came out, four times slower.

### Three ways to lose it

- **Growing detaches everything.** When a WebAssembly memory grows, every view on its old buffer goes dead. So the memory is sized once, when a model loads, with room set aside for the caller: about 32 MB for a TinyStories atlas, more for wider models.
- **Posting a view clones the whole memory.** `postMessage` copies a typed array's entire buffer, not the slice you see. Prism hands each helper thread's harvested activations back with `postMessage`, so those arrays stay ordinary on purpose.
- **Nothing is freed.** The allocator only bumps a pointer. Prism marks where the model ends and releases everything past it when you open another atlas. An early version allocated an encoder scratch buffer per run, a leak that would have filled the reserve and quietly sent every multiply back to JavaScript.

Prism spreads its reading across workers by passing messages. Sharing WebAssembly memory would require `SharedArrayBuffer` and cross-origin isolation headers, which would also affect embedded sites such as llms.ink. Message passing fits the way the desktop already works.

### Measuring it honestly

The same JavaScript growing run took 27 seconds in one browser session and 48.6 in another, with the machine more loaded. Comparisons across sessions were useless; the only numbers I trust are pairs run side by side, kernel on and off, on the same input.

## The other WebAssembly on this site

That kernel isn't the only WebAssembly here. Narrator, Doorman and the site's other model apps run on onnxruntime-web, and when there's no GPU, its engine is WebAssembly too. It runs on one thread here, for the same reason Prism passes messages: more threads would need `SharedArrayBuffer` and cross-origin isolation.

The same rule held there: measure the version you expect to be faster. Narrator's MMS-TTS voices come as an 8-bit file and a full-precision one, 38 MB against 114 MB. On the CPU, side by side in one session, the 8-bit file took 10.1 seconds to say 6 seconds of speech and the full-precision one 5.6. Narrator ships the bigger file. On the GPU, through WebGPU, the same model took 0.6 seconds, and Kokoro-82M takes 1.9 seconds on the GPU against 15 on the CPU for 6.8 seconds of speech. For these models, WebAssembly is the fallback that makes them run anywhere, not the fast path.

Growing the atlas took 48.6 seconds before this change and about 12 seconds after it, in the same browser session. Most of the work went into getting the memory right: keeping weights in one place, avoiding accidental copies, and catching an allocation that would eventually send everything back to JavaScript.

The Rust loop is 90 lines. Getting those 90 lines into the app was the interesting part. [Grow an atlas](https://lucasespindola.com/prism) and try it.
