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.
The toll on every call
Section titled “The toll on every call”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);node tradeoffs.mjs boundary1000000 calls of add(2, 3): JavaScript: 0.9 ms total, 0.9 ns per call wasm: 15.6 ms total, 15.6 ns per callThe 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);node tradeoffs.mjs peritemthe 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 7Twice 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.
Where the crossover sits
Section titled “Where the crossover sits”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:
for n in 100 1000 10000 100000; do node tradeoffs.mjs small $n; donedataset 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 calldataset 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 calldataset 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 calldataset 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.
What it costs to ship
Section titled “What it costs to ship”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:
ls -l pkg/fuzzy_search_bg.wasm pkg/fuzzy_search.js app/src/dataset.mjs app/src/engine.jsgzip -c pkg/fuzzy_search_bg.wasm | wc -cgzip -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 2715The 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.
The honest list
Section titled “The honest list”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.
Where the code stands
Section titled “Where the code stands”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:
git clone https://github.com/hackingwithrust/examplescd examples/react-wasmgit checkout wasm-ch08wasm-pack build --target webnode tradeoffs.mjs boundarynode tradeoffs.mjs small 100The track, end to end
Section titled “The track, end to end”Eight steps, one search: the track overview has the whole list, and every step’s code is a checkout away.
- What we’re building: Rust in the browser — the slow search and the benchmark to beat.
- Setting up wasm-bindgen and wasm-pack — the toolchain, and the pkg directory it emits.
- Calling Rust from JavaScript — what crosses the boundary, and what each kind costs.
- Choosing a real function to optimise — the profiler, and the win JavaScript was leaving on the table.
- Passing large data without copying — the corpus moves into wasm memory, once.
- Benchmarking WebAssembly honestly — warm-up, medians, and the numbers that survive scrutiny.
- Wiring WebAssembly into a React app — Vite, a worker, and the frame meter.
- 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.