Wiring WebAssembly into a React app
Step 7 of 8, and the search moves out of the benchmark harness into the thing it was measured
for: a React app. Steps 2 through 6 built the wasm module and timed it from Node. This step
loads it through Vite, calls it from React, moves it off the main thread into a Web Worker, and
puts a number on why that move matters — a frame meter that shows what each keystroke costs the
screen. The Rust side does not change; every line of src/lib.rs is exactly as step 6 left it.
Start from the step 6 end state — git checkout wasm-ch06 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,
Vite 8.3.4, React 19.3.1, headless Chrome 155, on an arm64 Mac.
The app
Section titled “The app”The module lives in react-wasm/; the app goes beside it in react-wasm/app/, and imports the
glue from ../pkg/ across that boundary. The finished tree:
react-wasm/├── Cargo.toml├── src/lib.rs ← untouched since step 6├── pkg/ ← wasm-pack output, rebuilt below└── app/ ├── index.html ├── package.json ├── vite.config.js └── src/ ├── main.jsx ├── App.jsx ├── dataset.mjs ├── engine.js └── search.worker.jsThe dependencies are two React packages and two dev-only tools. npm install writes the
versions it resolves; these are the ones this step ran under:
{ "name": "react-wasm-app", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" }, "dependencies": { "react": "^19.3.1", "react-dom": "^19.3.1" }, "devDependencies": { "@vitejs/plugin-react": "^6.1.2", "vite": "^8.3.4" }}If you scaffold with npm create vite instead of copying these files, your package name and
index.html will differ from the listings here; the wiring below is the same either way.
Vite and the pkg directory
Section titled “Vite and the pkg directory”Two lines of Vite configuration carry this step, and each one exists because the build fails without it. First the file:
import { defineConfig } from 'vite';import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], worker: { // The worker starts its engine with a top-level await, and Vite's build // bundles workers as IIFE by default — which does not allow it. format: 'es', }, server: { fs: { // pkg sits outside the app root, and the dev server refuses to serve // files it has not been told about: without this, every wasm fetch // comes back 403 Forbidden. allow: ['..'], }, },});server.fs.allow exists because the wasm glue is not inside the app. engine.js (below)
imports '../../pkg/fuzzy_search.js', which resolves one level above app/ — outside the
directory Vite treats as its root, so the dev server declines to serve it. Delete the allow
entry and the page renders, the switches render, and every search dies with the same error in
the console:
Error: failed to fetch Wasm: 403 Forbidden fetching 'http://localhost:5173/@fs/Users/rango/www/hackingwithrust/code/react-wasm/pkg/fuzzy_search_bg.wasm'The URL is this machine’s checkout; the shape — a /@fs/ path and a 403 — is what you will
see. The production build has no such restriction, which is why this setting bites only during
development.
worker.format exists because the dev server and the build disagree about what a worker is.
Run npm run build without it and the bundler refuses the file this step is about to write:
[UNSUPPORTED_FEATURE] Top-level await is currently not supported with the 'iife' output format ╭─[ src/search.worker.js:2:16 ] │ 2 │ const engine = await createEngine(); │ ──────────┬───────── │ ╰──────────────╯During development Vite serves the worker as an ES module — type: 'module' in the
constructor below asks for exactly that — and the top-level await that starts the engine
is fine. At build time Vite bundles workers as IIFE by default, an older format with no
top-level await, and the same line becomes an error. format: 'es' makes the build match the
dev server.
Why the search leaves the main thread
Section titled “Why the search leaves the main thread”The browser gives the main thread one budget: redraw at 60 Hz and every task on that thread must fit inside 16.7 ms, or the next frame is late. Step 6’s medians, laid against that budget and the 8.3 ms of a 120 Hz display:
// The wasm medians from step 6, laid against the frame budget. A display// redrawing at 60 Hz hands the main thread 16.7 ms per frame; at 120 Hz,// 8.3 ms. A search that outlasts its budget delays the next paint.fn main() { let medians = [ ("bracket", 22.8), ("steel bracket", 22.9), ("nylon pulley for tension", 24.5), ]; for (query, ms) in medians { for (hz, budget) in [(60, 16.7), (120, 8.3)] { println!("{query:26} {hz:3} Hz {ms:5.1} ms scan / {budget:4.1} ms frame = {:.1}x over", ms / budget); } }}bracket 60 Hz 22.8 ms scan / 16.7 ms frame = 1.4x overbracket 120 Hz 22.8 ms scan / 8.3 ms frame = 2.7x oversteel bracket 60 Hz 22.9 ms scan / 16.7 ms frame = 1.4x oversteel bracket 120 Hz 22.9 ms scan / 8.3 ms frame = 2.8x overnylon pulley for tension 60 Hz 24.5 ms scan / 16.7 ms frame = 1.5x overnylon pulley for tension 120 Hz 24.5 ms scan / 8.3 ms frame = 3.0x overStep 6 was honest about the other half of this: wasm only beat well-tuned JavaScript by 2–10%. Faster does not change the shape of the problem. A 23 ms scan on the main thread is a late frame per keystroke no matter what it is written in, which is why this step is about where the search runs, not only how fast it is.
One engine per context
Section titled “One engine per context”The worker and the main thread are separate JavaScript contexts, and each one that searches
needs its own copy of the module and the dataset. engine.js is that unit — one call to
createEngine() builds one instance of everything the search needs:
// One engine per JavaScript context: the worker holds one, and the main thread// builds one only if a search is asked to run there. Each engine owns its own// wasm module instance and its own copy of the corpus inside that instance's// memory — two engines means the 7.2 MB corpus crosses the boundary twice.import init, { load_dataset, search } from '../../pkg/fuzzy_search.js';import { buildItems, fuzzyScore } from './dataset.mjs';
export async function createEngine() { await init(); const items = buildItems(); const corpus = items.map((item) => `${item.title}\t${item.description}`).join('\n'); load_dataset(corpus);
// The step 1 search, for the JavaScript column of the comparison. function searchJS(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); }
// The wasm search: packed index/score pairs come back, the titles never // cross the boundary — this engine's items array is the display copy. function searchWasm(query) { const flat = search(query, 20); const hits = []; for (let i = 0; i < flat.length; i += 2) { hits.push({ title: items[flat[i]].title, score: flat[i + 1] }); } return hits; }
return { items, searchJS, searchWasm };}init() with no arguments is the --target web glue doing what it was generated to do:
fetch fuzzy_search_bg.wasm from beside itself and instantiate it. The two search functions
are the same pair every step since 1 has compared; the wasm one unpacks the packed
index/score pairs step 5 designed, against the items array this engine built for display.
dataset.mjs is step 1’s generator and scorer, unchanged, in a module both contexts can
import:
// The dataset: 100,000 catalogue items. Generated deterministically, so every// reader works through the same list — the same generator every step used.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'];
export function buildItems() { 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)}`, }); } return items;}
// Fuzzy subsequence score of query against text. -1 means no match. Step 1's// function, unchanged.export 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;}The worker owns the module
Section titled “The worker owns the module”The worker is small because the engine holds everything. It builds its engine once, when the module evaluates, and answers messages:
// The worker owns the wasm module and the dataset. The main thread never// touches either: it posts { id, query, backend } and gets { id, hits, ms }.// Messages posted while the engine is still starting queue until module// evaluation finishes, so the first keystroke never gets lost.import { createEngine } from './engine.js';
const engine = await createEngine();
self.onmessage = (event) => { const { id, query, backend } = event.data; const t0 = performance.now(); const hits = backend === 'js' ? engine.searchJS(query) : engine.searchWasm(query); const ms = performance.now() - t0; self.postMessage({ id, hits, ms });};The top-level await is the line Vite’s default build format rejected above. Here it is
doing useful work: by the time self.onmessage is assigned, the wasm is instantiated and the
dataset has crossed the boundary. A keystroke typed before that finishes is not lost — module
workers hold their first messages until evaluation completes, which is why the app needs no
readiness handshake.
performance.now() is available inside a worker, so the search duration is measured where the
search runs, the same way bench.mjs measured it in step 6.
The interface, and a frame meter
Section titled “The interface, and a frame meter”App.jsx holds the two switches — where the search runs, and which engine answers — plus the
meter that turns this step’s argument into a number on screen. requestAnimationFrame fires
once per displayed frame, and the meter times those calls with the wall clock —
performance.now() inside the callback, not the timestamp argument the browser passes in.
The argument is a trap in headless Chrome: it stays on the display’s 16.7 ms grid even when
the frame ran late, so a 30 ms block on the main thread reads 16.6 on the argument and 30.2
on the clock. The meter publishes the worst gap in each five-second window. It lives on the
main thread by definition: the main thread is the thing being measured:
import { useEffect, useRef, useState } from 'react';import { createEngine } from './engine.js';
export default function App() { const [query, setQuery] = useState(''); const [backend, setBackend] = useState('wasm'); const [inWorker, setInWorker] = useState(true); const [hits, setHits] = useState([]); const [stats, setStats] = useState(null); const [frames, setFrames] = useState({ worst: 0 });
// The worker owns the wasm module and the dataset. It starts building its // engine as the page loads; keystrokes typed before that queue inside the // worker and are answered when the engine is ready. const workerRef = useRef(null); useEffect(() => { const worker = new Worker(new URL('./search.worker.js', import.meta.url), { type: 'module', }); worker.onmessage = (event) => { const { hits, ms } = event.data; setHits(hits); setStats({ ms, where: 'worker' }); }; workerRef.current = worker; return () => worker.terminate(); }, []);
// A second engine, on the main thread, built only when a search is asked to // run there. That is the mode this step exists to measure against. const mainRef = useRef(null); const ensureMainEngine = async () => { if (!mainRef.current) mainRef.current = await createEngine(); return mainRef.current; };
// The frame meter. requestAnimationFrame fires once per displayed frame, and // the wall clock between two calls is how long a frame really took. The // callback's timestamp argument is no good here: headless Chrome hands it out // on the display's 16.7 ms grid even when the frame ran late — a 30 ms block // on the main thread measured 16.6 on the argument, 30.2 on performance.now(). // The worst gap in each five-second window is published to the page. useEffect(() => { let raf; let last = performance.now(); let worst = 0; const tick = () => { const now = performance.now(); if (now - last > worst) worst = now - last; last = now; raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); const publish = setInterval(() => { setFrames({ worst }); worst = 0; }, 5000); return () => { cancelAnimationFrame(raf); clearInterval(publish); }; }, []);
async function onQuery(value) { setQuery(value); if (!value) { setHits([]); setStats(null); return; } if (inWorker) { workerRef.current?.postMessage({ id: Date.now(), query: value, backend }); } else { const engine = await ensureMainEngine(); const t0 = performance.now(); const found = backend === 'js' ? engine.searchJS(value) : engine.searchWasm(value); setHits(found); setStats({ ms: performance.now() - t0, where: 'main thread' }); } }
return ( <main> <h1>Fuzzy search</h1> <p>100,000 items. Type to search; the frame meter below shows what the typing costs.</p> <input autoFocus aria-label="search the catalogue" value={query} onChange={(e) => onQuery(e.target.value)} /> <label> search runs in{' '} <select value={inWorker ? 'worker' : 'main'} onChange={(e) => setInWorker(e.target.value === 'worker')}> <option value="worker">a worker</option> <option value="main">the main thread</option> </select> </label> <label> engine{' '} <select value={backend} onChange={(e) => setBackend(e.target.value)}> <option value="wasm">Rust / wasm</option> <option value="js">JavaScript</option> </select> </label> <p className="meter"> {stats ? `search: ${stats.ms.toFixed(1)} ms on the ${stats.where} · ${hits.length} hits` : 'type to search'} </p> <p className="meter">worst frame gap, last 5 s: {frames.worst.toFixed(1)} ms</p> <ol> {hits.map((hit) => ( <li key={hit.title}> {hit.title} <span className="score">{hit.score}</span> </li> ))} </ol> </main> );}The worker instantiation is the other Vite-relevant line: new Worker(new URL('./search.worker.js', import.meta.url), { type: 'module' }) is the form Vite’s build can find and bundle. A worker
constructed from a bare string path would be served but never processed, and the build would
ship without the worker or the wasm it imports.
The remaining two files are small. main.jsx mounts the component:
import { createRoot } from 'react-dom/client';import App from './App.jsx';
createRoot(document.getElementById('root')).render(<App />);and index.html is the shell Vite treats as its entry:
<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Fuzzy search — Rust in the browser</title> </head> <body> <div id="root"></div> <script type="module" src="/src/main.jsx"></script> </body></html>See it work
Section titled “See it work”Rebuild the module if pkg/ is not there, then start the dev server:
cd react-wasmwasm-pack build --target webcd appnpm installnpm run dev VITE v8.3.4 ready in 245 ms
➜ Local: http://localhost:5173/ ➜ Network: use --host to exposeThen type “steel bracket” and watch the two meter lines. These are five runs on this machine’s checkout, one search per switch combination, worst frame gap from the five-second window the search landed in (headless Chrome 155 on an arm64 Mac; the times are this machine’s, the shape is not):
worker + wasm — search: 35.3 ms on the worker · 20 hits worst frame gap, last 5 s: 18.8 msmain + wasm (1) — search: 23.6 ms on the main thread · 20 hits worst frame gap, last 5 s: 86.5 msmain + wasm (2) — search: 24.8 ms on the main thread · 20 hits worst frame gap, last 5 s: 34.9 msmain + js — search: 51.7 ms on the main thread · 20 hits worst frame gap, last 5 s: 58.4 msworker + js — search: 53.5 ms on the worker · 20 hits worst frame gap, last 5 s: 17.8 msRead the frame column first. The high teens are the floor — an undisturbed 60 Hz frame in this headless browser plus the meter’s own jitter. Both worker rows sit on it: while the search ran, the main thread never missed a beat, because the search was not on it. The main-thread rows sit two and three floors down: each search blocked the screen for every frame it lasted. And the highest number, 86.5 ms, is not the search at all — it came in the window where the app built the main-thread engine, wasm instance, corpus, dataset and all, on the thread that draws the screen.
Now the search column — read it down the diagonal, same thread against same engine. On the main thread, the wasm search took 24.8 ms and the JavaScript one 51.7; in the worker, 35.3 against 53.5. Within a thread the Rust module roughly halves the time, the same ordering step 6 measured. Worker against main thread is the noisier comparison — the worker’s wasm row ran slower than the main thread’s this time, and five single runs will not settle why — which is exactly why the frame column, not the stopwatch, is the argument for the worker. Step 6’s Node medians do not carry over either: a browser tab brings rendering pressure a Node process does not have, and comparing Node to browser across articles is the two-environments mistake step 6 warned about.
The production build ships the whole thing as four assets:
npm run buildvite v8.3.4 building client environment for production...transforming...✓ 18 modules transformed.rendering chunks...computing gzip size...dist/index.html 0.34 kB │ gzip: 0.25 kBdist/assets/search.worker-CmASaIIN.js 5.00 kBdist/assets/fuzzy_search_bg-DY07CiSq.wasm 53.42 kB │ gzip: 24.83 kBdist/assets/index-C34gllVG.js 226.53 kB │ gzip: 71.39 kB
✓ built in 204msThe hashes are this build’s; the sizes are the story. The wasm file is 53.42 kB, 24.83 kB
gzipped — step 6’s module, byte for byte, now an asset alongside the app. npm run preview
serves dist/, and the production build answers the same queries: “nylon pulley for tension”
came back in 30.3 ms on the worker with nylon pulley #273 at the top — the same best hit
every step in this track has produced, from step 1’s JavaScript to a bundled Rust module in a
worker.
Where the code stands
Section titled “Where the code stands”This step’s end state is tagged wasm-ch07 in
the examples repository. The app/ directory is
new; src/lib.rs, Cargo.toml, and the step 3–6 harnesses (check.mjs, check-search.mjs,
bench.mjs) are untouched and still pass. Checking out the tag puts your tree where this
article ends:
git clone https://github.com/hackingwithrust/examplescd examples/react-wasmgit checkout wasm-ch07wasm-pack build --target webcd appnpm installnpm run devStep 8, When WebAssembly is the wrong answer, closes the track by paying its own bill: bundle size, boundary crossings at small input sizes, and the honest list of cases where the answer is plain JavaScript. The track overview keeps the full list current as articles land.