Skip to content
Select theme

When WebAssembly is the wrong answer

Step 8 of 8, and the track closes by arguing against itself. Steps 1 through 7 took one slow JavaScript search, moved it into Rust, and made it faster — 100,000 items scanned in 24 ms instead of 52, in a worker that never costs a frame. Every one of those numbers came with a condition attached, and this step is where the conditions get paid. Three measurements, one new script, no Rust: the fixed toll every call across the boundary pays, the dataset size below which that toll wins, and what the module adds to the bundle.

Start from the step 7 end state — git checkout wasm-ch07 in your clone of the examples repository — or from that article’s own steps. Versions used here: Rust 1.99.0, wasm-pack 0.15.0, wasm-bindgen 0.2.129, Node v22.22.2, on an arm64 Mac. Your times will land near, not on, the numbers below.

tradeoffs.mjs, new next to Cargo.toml, has three modes. The first times the smallest possible export — add(2, 3), the two-integer function step 3 started with — against the same function written in JavaScript, a million calls each. The timing core of the script:

// One million calls of the smallest possible export, against the same
// function written in JavaScript. The difference is the toll: argument
// conversion, the call into the instance, the result crossing back.
const addJS = (a, b) => a + b;
const addWasm = wasm.add;
const N = 1_000_000;
let sink = 0;
const run = (fn) => {
const t0 = performance.now();
for (let i = 0; i < N; i++) sink += fn(2, 3);
return performance.now() - t0;
};
run(addJS); // tier up first; only the second run of each is reported
const js = run(addJS);
run(addWasm);
const wasmMs = run(addWasm);
Terminal window
node tradeoffs.mjs boundary
1000000 calls of add(2, 3):
JavaScript: 0.9 ms total, 0.9 ns per call
wasm: 15.6 ms total, 15.6 ns per call

The JavaScript call is inlined down to an add instruction; the wasm call goes through the glue, converts its arguments, enters the instance, and comes back. That is the 15 ns — the price of the border itself, before the function on the other side has done anything.

For this track’s design the toll is irrelevant: one search call per keystroke, 15 ns against a 24 ms scan. It becomes the whole story the moment the design crosses once per item instead of once per interaction. The third mode of tradeoffs.mjs runs that anti-pattern on purpose — the same 100,000-item scan, but the score of every title and every description comes back through its own fuzzy_score call, 200,000 crossings per search. The script calls both scorers through the same loop and refuses to print a number if the hit counts disagree:

// The anti-pattern: calling wasm once per item. Same 100,000-item dataset,
// same scoring, but the boundary is crossed 200,000 times per search —
// every string copied in and a score copied back, item by item.
const items = buildItems(100_000);
const query = 'bracket';
const searchPerItem = (score) => {
const hits = [];
for (const item of items) {
const s = Math.max(score(query, item.title), score(query, item.description));
if (s >= 0) hits.push(s);
}
hits.sort((a, b) => b - a);
return hits.length;
};
const jsHits = searchPerItem(fuzzyScore);
const wasmHits = searchPerItem(wasm.fuzzy_score);
Terminal window
node tradeoffs.mjs peritem
the same scan, one wasm call per item (12504 hits, query "bracket"):
JavaScript, no boundary: median 30.0 ms of 7
wasm, 200,000 calls: median 60.1 ms of 7

Twice as slow as the JavaScript it was supposed to replace — and this is the integer toll. Every one of those 200,000 calls also copies a string into wasm memory and folds it there, the price step 3 itemised. The same search batched the way step 5 built it — one crossing, packed pairs back — ran 23.9 ms in step 6. Same module, same data, same query: 24 ms batched, 60 ms per item. If the calls are small and many, the design is wrong, and no amount of Rust fixes it.

The second mode answers the smaller question: how much data does the scan need before it outgrows the toll? Each dataset size runs in a fresh process, because load_dataset can only be called once — the DATASET static is a OnceLock, the step 5 design that keeps the corpus from ever being copied again:

Terminal window
for n in 100 1000 10000 100000; do node tradeoffs.mjs small $n; done
dataset 100 items, query "bracket", top hit steel bracket #0 (matches JS: true)
JavaScript: median 0.03 ms of 7
wasm: median 0.05 ms of 7
wasm scan only, inside the module: 0.026 ms per call
dataset 1000 items, query "bracket", top hit steel bracket #0 (matches JS: true)
JavaScript: median 0.34 ms of 7
wasm: median 0.28 ms of 7
wasm scan only, inside the module: 0.227 ms per call
dataset 10000 items, query "bracket", top hit steel bracket #0 (matches JS: true)
JavaScript: median 2.95 ms of 7
wasm: median 2.27 ms of 7
wasm scan only, inside the module: 2.304 ms per call
dataset 100000 items, query "bracket", top hit steel bracket #0 (matches JS: true)
JavaScript: median 29.81 ms of 7
wasm: median 23.85 ms of 7
wasm scan only, inside the module: 23.820 ms per call
items JavaScript wasm faster
100 0.03 ms 0.05 ms JavaScript
1,000 0.34 ms 0.28 ms wasm
10,000 2.95 ms 2.27 ms wasm
100,000 29.81 ms 23.85 ms wasm

The 100-item row is the only one in this track where wasm loses. At that size the scan inside the module is 0.026 ms — about half of what the whole call costs from JavaScript, with the toll, the fresh result vector and the glue unpacking making up the other half. JavaScript pays none of that, so it wins. Somewhere between 100 and 1,000 items the scan outgrows the border, and the gap only widens from there: the scan scales with the data, the toll does not.

Be honest about the magnitude too: at 100 items both numbers are thousandths of a frame, and no interface notices the difference. The direction is what matters. A dataset that fits in a handful of screens gives wasm nothing to accelerate — the toll is the entire cost, and the JavaScript never had it.

The third bill arrives before the first search runs. The step 7 build shipped the module as an asset; here is the whole weight of the Rust route on disk:

Terminal window
ls -l pkg/fuzzy_search_bg.wasm pkg/fuzzy_search.js app/src/dataset.mjs app/src/engine.js
gzip -c pkg/fuzzy_search_bg.wasm | wc -c
gzip -c pkg/fuzzy_search.js | wc -c
-rw-r--r--@ 1 rango staff 1408 Oct 21 08:06 app/src/dataset.mjs
-rw-r--r--@ 1 rango staff 1465 Oct 21 08:06 app/src/engine.js
-rw-r--r--@ 1 rango staff 53421 Oct 21 10:45 pkg/fuzzy_search_bg.wasm
-rw-r--r--@ 1 rango staff 10613 Oct 21 10:45 pkg/fuzzy_search.js
24814
2715

The module is 53,421 bytes, 24,814 gzipped, and the glue that loads it is another 10,613 bytes, 2,715 gzipped — about 27 kB compressed that the page downloads before it can search at all. The JavaScript it replaces is the generator, the scorer and the search loop: 2,873 bytes of source, so small that splitting hairs over its gzip size is beside the point.

Two honest footnotes. The module carries bench_search, step 6’s timing hook, even though the app never calls it — measurement tools ride along unless they are behind a cargo feature. And 27 kB buys proportion: React itself in the step 7 build was 226.5 kB, 71.4 kB gzipped, so the wasm route adds roughly a third of React’s weight, not the heaviest thing on the page. Size alone is rarely the disqualifying argument; it is one entry in a sum.

The heavier weight is not on disk. The corpus is 7,222,211 bytes, and step 5’s design has it crossing the boundary once and living in wasm memory for the page’s lifetime. Step 7’s app holds a second engine whenever a search is asked to run on the main thread — a second wasm instance, a second copy of those 7.2 MB. One context, fine; two, doubled; a search per tab, multiplied again. RAM is not bundle size, but it is the same dataset being paid for twice.

Everything the track measured, reduced to when the answer is plain JavaScript:

  • Small data. Under about a thousand items here, the toll is the whole cost. The crossover moves with the workload — a heavier per-item computation pushes it lower, a lighter one higher — so measure it the way this step did rather than quoting these rows.
  • A call per item. 60 ms against 24 ms for the same scan. Cross once per interaction with a batch, or do not cross at all.
  • The work is the DOM. WebAssembly has no way to touch the DOM except by calling back into JavaScript — every call over the same border, in both directions. A function whose time goes to React state and rendering has no compute for Rust to accelerate.
  • The JavaScript is already fast enough. Step 6’s headline was honest about this: wasm beat the tuned JavaScript by a few percent, and only beat step 1’s untuned original because the profiler in step 4 had already handed most of the win back to JavaScript for free.

And the case for Rust, which the track spent seven steps earning: a large input, one crossing per interaction, pure computation in between. 100,000 items scanned in 24 ms off the main thread, with a frame meter that never leaves the high teens. That case is real. It is just narrower than the talks make it look.

This step’s end state is tagged wasm-ch08 in the examples repository. tradeoffs.mjs is the only new file; nothing else has moved since step 7. Checking out the tag puts your tree where this article — and the track — ends:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/react-wasm
git checkout wasm-ch08
wasm-pack build --target web
node tradeoffs.mjs boundary
node tradeoffs.mjs small 100

Eight steps, one search: the track overview has the whole list, and every step’s code is a checkout away.

  1. What we’re building: Rust in the browser — the slow search and the benchmark to beat.
  2. Setting up wasm-bindgen and wasm-pack — the toolchain, and the pkg directory it emits.
  3. Calling Rust from JavaScript — what crosses the boundary, and what each kind costs.
  4. Choosing a real function to optimise — the profiler, and the win JavaScript was leaving on the table.
  5. Passing large data without copying — the corpus moves into wasm memory, once.
  6. Benchmarking WebAssembly honestly — warm-up, medians, and the numbers that survive scrutiny.
  7. Wiring WebAssembly into a React app — Vite, a worker, and the frame meter.
  8. When WebAssembly is the wrong answer — this page.

The final numbers, one last time: 52 ms of JavaScript became 24 ms of wasm, in a worker, for 27 kB gzipped and 7.2 MB of memory — and below a thousand items, plain JavaScript was faster all along.