Choosing a real function to optimise
Step 4 of 8, and it writes no Rust — it decides where the Rust will go. Step 1 left a search that
cost tens of milliseconds per keystroke, and step 3 left the
boundary working with a price list attached:
numbers cross in registers, strings cross through memory twice. This step adds one file,
profile.mjs, runs the profiler on the search, and answers the question the whole track turns on:
which function is worth the wasm treatment. The answer comes from a profiler rather than a hunch,
and part of it is not what the track was promised — a slice of the cost goes away with two lines
of JavaScript, and the track takes that win before reaching for Rust.
Start from the step 3 end state — git checkout wasm-ch03 in your clone of the
examples repository — or from that article’s own
steps. Everything here runs under Node v22.22.2 on an arm64 Mac; your times will land near, not
on, the numbers below.
The harness
Section titled “The harness”profile.mjs, new next to Cargo.toml, is step 1’s benchmark in a form a profiler can attach to.
It carries two implementations of the same search: original, which is step 1’s code untouched,
and lowered, a variant whose right to exist the profile below has to earn:
// profile.mjs — the step 1 benchmark, in a form a profiler can attach to.//// node profile.mjs times both search variants// node --prof profile.mjs original profiles only the step 1 original//// "original" is step 1's code, unchanged. "lowered" is the same search with// the dataset's case folding done once, at build time, instead of on every// call. The dataset is generated deterministically, so the best hits are the// same on every machine; the times are not, which is why the script reports// a median of 7 runs.
const WHICH = process.argv[2] ?? "both";if (WHICH !== "both" && WHICH !== "original" && WHICH !== "lowered") { console.error(`profile.mjs: unknown variant "${WHICH}" (use original, lowered, or nothing)`); process.exit(1);}
// 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)}`, });}
// 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);}
// The same scan, but the dataset's case folding happened once, above, and the// query folds once per search instead of twice per call.const lowered = items.map((item) => ({ title: item.title.toLowerCase(), description: item.description.toLowerCase(),}));
function scoreLower(q, text) { let score = 0; let qi = 0; let streak = 0; for (let ti = 0; ti < text.length && qi < q.length; ti++) { if (text[ti] === q[qi]) { qi++; streak++; score += 2 + streak; } else { streak = 0; } } if (qi < q.length) return -1; return score;}
function searchLowered(query) { const q = query.toLowerCase(); const hits = []; for (const item of lowered) { const score = Math.max(scoreLower(q, item.title), scoreLower(q, item.description)); if (score >= 0) hits.push({ title: item.title, score }); } hits.sort((a, b) => b.score - a.score); return hits.slice(0, 20);}
function bench(label, fn) { fn(); // 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 = fn(); times.push(performance.now() - t0); best = hits[0]; } times.sort((a, b) => a - b); console.log(`${label}: ${times[3].toFixed(1)} ms (median of 7), best "${best.title}"`);}
const queries = ["bracket", "steel bracket", "nylon pulley for tension"];
if (WHICH === "both" || WHICH === "original") { for (const q of queries) bench(`original, query "${q}"`, () => search(items, q));}if (WHICH === "both" || WHICH === "lowered") { for (const q of queries) bench(`lowered, query "${q}"`, () => searchLowered(q));}The bench function is step 1’s harness — one warm-up, seven timed runs, median — with a label.
The variant argument exists for the profiler: a profile of a run that exercises both variants
would mix their ticks, so each one can be profiled alone.
Run it
Section titled “Run it”node profile.mjsoriginal, query "bracket": 29.5 ms (median of 7), best "steel bracket #0"original, query "steel bracket": 30.7 ms (median of 7), best "steel bracket #0"original, query "nylon pulley for tension": 30.2 ms (median of 7), best "nylon pulley #273"lowered, query "bracket": 24.0 ms (median of 7), best "steel bracket #0"lowered, query "steel bracket": 24.6 ms (median of 7), best "steel bracket #0"lowered, query "nylon pulley for tension": 24.3 ms (median of 7), best "nylon pulley #273"The original prints about 30 ms where step 1 published about 40 — same machine, same Node, same
code. That drift is the case for measuring instead of remembering, and it is why every number in
this track is a median rather than a single run. The lowered variant is consistently faster by
roughly five milliseconds. The best hit agrees on every line, and the full top-20 lists are
identical for all three queries — the fold moved, the scores did not. Where those five
milliseconds come from is the profile’s job to say.
Profile the original
Section titled “Profile the original”rm -f isolate-*.lognode --prof profile.mjs original > /dev/nullnode --prof-process isolate-*.log--prof runs the program under V8’s sampling profiler and writes a log into the current
directory; --prof-process turns the log into a report. (A warning about VM Modules may appear
on stderr depending on your Node install — it is not part of the report.) The report runs for
pages, but the story is in the two sections near the top:
Statistical profiling result from isolate-0x758480c000-20766-v8.log, (589 ticks, 116 unaccounted, 0 excluded).
[JavaScript]: ticks total nonlib name 386 65.5% 65.5% JS: *search file:///Users/rango/www/hackingwithrust/code/react-wasm/profile.mjs:56:16 58 9.8% 9.8% Builtin: StringToLowerCaseIntl 8 1.4% 1.4% Builtin: ArrayIteratorPrototypeNext 5 0.8% 0.8% Builtin: StringAdd_CheckNone 3 0.5% 0.5% Builtin: ArrayTimSort 2 0.3% 0.3% JS: *<anonymous> file:///Users/rango/www/hackingwithrust/code/react-wasm/profile.mjs:62:13 2 0.3% 0.3% Builtin: LoadIC 2 0.3% 0.3% Builtin: KeyedLoadIC 1 0.2% 0.2% JS: *fuzzyScore file:///Users/rango/www/hackingwithrust/code/react-wasm/profile.mjs:36:20 1 0.2% 0.2% Builtin: ToString 1 0.2% 0.2% Builtin: StrictEqual_Baseline 1 0.2% 0.2% Builtin: SortCompareUserFn 1 0.2% 0.2% Builtin: LessThan_Baseline 1 0.2% 0.2% Builtin: FastNewClosure 1 0.2% 0.2% Builtin: DivideSmi_Baseline
[Summary]: ticks total nonlib name 473 80.3% 80.3% JavaScript 0 0.0% 0.0% C++ 23 3.9% 3.9% GC 0 0.0% Shared libraries 116 19.7% UnaccountedThe isolate number and process id in the first line are the profiler’s own; yours will differ, as will the file path in the entries — that is this machine’s checkout. Read the entries like this:
searchholds 65.5% of all ticks, andfuzzyScoregets 0.2%. Not because the score loop is cheap — because V8 inlined the small function into its caller, so its loop is billed tosearch. A flat profile says “it is allsearch” and tells the truth; it does not say which part ofsearch.Builtin: StringToLowerCaseIntlat 9.8% is the lowercasing from insidefuzzyScore, visible as its own line on this run. Run the same script again and that line may vanish intosearch— V8’s inliner changes its mind between runs. The work is there either way.- GC at 3.9% is allocation pressure. The arithmetic behind it is next.
Unaccounted— nearly a fifth of the samples landed in frames the profiler could not name. A regular sight in sampling profiles on macOS; treat it as blur, not as hidden cost.
The same data has a second road: node --cpu-prof --cpu-prof-name original.cpuprofile profile.mjs original writes the profile as a .cpuprofile file — 21,727 bytes here — which is
the format Chrome DevTools’ Performance panel loads. That pays off when the search lives in a
browser tab, which is step 7’s address.
Where five milliseconds come from
Section titled “Where five milliseconds come from”Every keystroke runs 200,000 fuzzyScore calls — two per item, title and description. Each call
lowercases both of its arguments before scanning:
const q = query.toLowerCase(); const t = text.toLowerCase();That is 400,000 short-lived strings per keystroke. The expensive half is t: 200,000 fresh
copies of fields that never change — 7,022,212 characters folded and discarded on every
keystroke. StringToLowerCaseIntl’s 9.8% is that folding; GC’s 3.9% is burying the results.
The lowered variant does the folding once, at build time:
// The same scan, but the dataset's case folding happened once, above, and the// query folds once per search instead of twice per call.const lowered = items.map((item) => ({ title: item.title.toLowerCase(), description: item.description.toLowerCase(),}));scoreLower is fuzzyScore without the fold, and searchLowered folds the query once per
search instead of twice per call. Nothing else moves — the scan walks the same characters in the
same order, which is why the top-20 lists come back identical.
The decision
Section titled “The decision”Five of the thirty milliseconds were never a wasm problem, and now they are gone — claimed by two
lines of JavaScript that the profile, not intuition, pointed at. The remaining 24 ms is the scan
itself: seven million characters, one comparison at a time, proportional to the corpus. No
refactor of the JavaScript removes that work, and that scan — not search, not the sort, not the
microseconds of glue — is the real function to optimise.
Step 3 already priced the wasm version of it: strings cross through memory twice per call, so scanning from wasm the way step 3 does it means 200,000 crossings per keystroke. That makes the target sharper than “rewrite the loop in Rust”: the win is in changing what crosses the boundary. Step 5, Passing large data without copying, moves the dataset into wasm memory so a keystroke crosses it once; step 6 measures the result in a way that survives scrutiny.
Where the code stands
Section titled “Where the code stands”This step’s end state is tagged wasm-ch04 in
the examples repository. profile.mjs is new;
nothing else changes — Cargo.toml, src/lib.rs and check.mjs are exactly as step 3 left
them. Checking out the tag puts your tree where this article ends:
git clone https://github.com/hackingwithrust/examplescd examples/react-wasmgit checkout wasm-ch04node profile.mjsStep 5 moves the 100,000 items into wasm memory and hands JavaScript a view instead of a copy — the change the profile in this step argued for. The track overview keeps the full list current as articles land.