Skip to content
Select theme

What we're building: Rust in the browser

Step 1 of 8, and it writes no Rust. This is the map: one slow JavaScript function, the number it costs on every keystroke, and the seven steps between here and there. By the end of the track the same function runs in Rust compiled to WebAssembly inside a React app — with a benchmark that proves what got faster and, just as important, a section on when the whole exercise is not worth it.

The app is a command-palette-style search over a catalogue of 100,000 items. Each item has a title and a description; every keystroke scores every item against the query and shows the top 20. The scoring is a fuzzy subsequence match — the same idea behind Ctrl-P and Spotlight:

// The dataset: 100,000 catalogue items. Generated deterministically, so every
// reader works through the same list.
const MATERIALS = ["steel", "brass", "carbon", "nylon", "ceramic", "alloy"];
const KINDS = ["bracket", "spacer", "gear", "clamp", "hinge", "pulley", "bearing", "fastener"];
const USES = ["damping", "mounting", "rotation", "sealing", "support", "tension"];
const items = [];
for (let i = 0; i < 100_000; i++) {
const material = MATERIALS[i % 6];
const kind = KINDS[Math.floor(i / 6) % 8];
const use = USES[Math.floor(i / 48) % 6];
items.push({
title: `${material} ${kind} #${i}`,
description: `${material} ${kind} for ${use}, rated ${100 + (i % 40)} kg, batch ${9000 + (i % 900)}`,
});
}

Two functions do the work. fuzzyScore walks the text once, keeping score for consecutive matches; search calls it on every item — against both the title and the description — sorts the survivors, and takes the top 20:

// Fuzzy subsequence score of query against text. -1 means no match.
function fuzzyScore(query, text) {
const q = query.toLowerCase();
const t = text.toLowerCase();
let score = 0;
let qi = 0;
let streak = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
qi++;
streak++;
score += 2 + streak; // consecutive matches are worth more
} else {
streak = 0;
}
}
if (qi < q.length) return -1; // ran out of text before running out of query
return score;
}
// Runs once per keystroke in the real app.
function search(items, query) {
const hits = [];
for (const item of items) {
const score = Math.max(fuzzyScore(query, item.title), fuzzyScore(query, item.description));
if (score >= 0) hits.push({ title: item.title, score });
}
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, 20);
}

Nothing here is stupid code. It is the version most people write first, and it is string-shaped work — allocation, case folding, char-by-char scanning — which is exactly where the browser’s JavaScript engine spends the most time. Here is the benchmark harness: warm up once (step 6 explains why that matters), then time seven runs per query and report the median:

const queries = ["bracket", "steel bracket", "nylon pulley for tension"];
for (const q of queries) {
search(items, q); // warm-up: step 6 explains why this matters
const times = [];
let best = null;
for (let i = 0; i < 7; i++) {
const t0 = performance.now();
const hits = search(items, q);
times.push(performance.now() - t0);
best = hits[0];
}
times.sort((a, b) => a - b);
console.log(`query "${q}": ${times[3].toFixed(1)} ms (median of 7), best "${best.title}"`);
}

Run the assembled script with Node v22.22.2 on an arm64 Mac and the numbers come back like this — your machine will land somewhere near, not on, these values. Even re-running this script on the same machine moves the middle digits around; that instability is its own subject in step 6:

query "bracket": 40.2 ms (median of 7), best "steel bracket #0"
query "steel bracket": 37.1 ms (median of 7), best "steel bracket #0"
query "nylon pulley for tension": 39.7 ms (median of 7), best "nylon pulley #273"

40 milliseconds per keystroke does not sound like much until you put it next to the frame budget. A screen updating at 60 fps gives the main thread 16.7 ms per frame; one call to search eats more than two of them before React has rendered anything:

frame budget, 60 fps │█████████████████│ 16.7 ms
search(), JavaScript │████████████████████████████████████████│ 40.2 ms

Both bars are at the same scale. And a user typing “steel bracket” is not making one call — that is 13 keystrokes, 13 searches, roughly half a second of main-thread work smeared across the typing, on top of React’s own re-render. That is the laggy-palette feel this track is after.

Two honesty notes before the number becomes a target. First, these figures come from Node, not a browser tab; the browser brings its own rendering pressure, and step 6 measures there properly — warm-up handled, medians reported, and a look at why most published wasm benchmarks get this wrong. Second, some of this cost is fixable in JavaScript alone — the code above re-lowercases every title on every keystroke — and step 4 does that first, profiling before optimising. Rust gets its turn after the JavaScript has been made honest.

What “beat” means here: the same top-20 list for the same queries, in less time per keystroke, with the cost of shipping a .wasm bundle counted against the win. Step 8 is where the answer turns out to be no — small inputs, frequent boundary crossings, and bundle size all cost real money, and the track ends by paying its own bill.

Published in this order. Each step ends at a state the next one picks up from.

# Article What lands
2 Setting up wasm-bindgen and wasm-pack the toolchain, and the pkg directory it emits
3 Calling Rust from JavaScript strings, numbers, and what crosses the boundary
4 Choosing a real function to optimise profile first, or you will optimise nothing
5 Passing large data without copying views over wasm memory
6 Benchmarking WebAssembly honestly JIT warm-up, and why most published numbers are wrong
7 Wiring WebAssembly into a React app Vite, workers, and keeping the UI responsive
8 When WebAssembly is the wrong answer size, boundary crossings, and small inputs

The shape of it: step 2 puts the Rust-to-wasm toolchain on the machine, step 3 pushes the first values across the boundary. Step 4 picks the real target — the function above — and profiles it. Steps 5–6 are the spine: moving the dataset without copying it, then measuring the result in a way that survives scrutiny. Step 7 lands the whole thing in React behind Vite, in a Web Worker so typing stays smooth. Step 8 keeps score honestly, including the cases where wasm loses.

The Rust side of every step is written and verified against Rust 1.99.0 with edition 2024 — what a current cargo new produces. Step 2 installs wasm-pack and the wasm32-unknown-unknown target; Node and npm carry the front end all the way through.

  • Rust, installed and current. rustup.rs installs it or brings an old install up to date.
  • Node and npm, plus working knowledge of React and the browser dev tools. The React side stays small, but this track does not teach React.
  • Variables, types, and functions — enough Rust syntax to write real code.
  • Collections and strings — you will pass Vec and String across the boundary in step 3.
  • Ownership and moves — the model behind why the Rust side is fast, and behind the copying rules in step 5.

No WebAssembly experience assumed — that is what the track is for.

Steps that change the project commit the result to react-wasm/ in the examples repository and tag it — wasm-ch02 after the setup step, and so on. If your build misbehaves partway through, checking out that tag puts your tree exactly where the article left it. This article commits nothing; the first commit lands with article 2.

Step 2 sets up the toolchain: wasm-pack, the wasm32-unknown-unknown target, and the pkg directory of glue that turns a Rust function into something a browser can import. The track overview keeps the full list current as articles land.