Passing large data without copying
Step 5 of 8, and it is the step the profiler asked for. Step 3 left every fuzzy_score call
copying its arguments into wasm memory, and step 4’s profile put 200,000 of those calls on every
keystroke. This step moves the 100,000-item dataset into wasm memory — once — and leaves each
keystroke a single crossing: the query in, packed scores out. It also opens the road the design
rests on: JavaScript reading wasm memory directly, through a Uint8Array view, with nothing
copied in between.
Start from the step 4 end state — git checkout wasm-ch04 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.
One copy in
Section titled “One copy in”Cargo.toml does not change this step; it is the one from step 2, and wasm-bindgen is the
only dependency the code in this article needs:
[package]name = "fuzzy-search"version = "0.1.0"edition = "2024"
[lib]crate-type = ["cdylib"]
[dependencies]wasm-bindgen = "0.2"The corpus travels as one string: one item per line, title and description separated by a tab.
load_dataset receives it, records where each item’s two fields sit, and keeps the bytes. This
is the new half of src/lib.rs — the two use lines come from the top of the file, the rest
runs from fuzzy_score to the end; add, expand and shout are untouched:
use std::sync::OnceLock;use wasm_bindgen::prelude::*;
// The scoring function from step 1, ported. -1 still means no match, so the// JavaScript and Rust versions can be compared call for call.#[wasm_bindgen]pub fn fuzzy_score(query: &str, text: &str) -> i32 { let q: Vec<char> = query.to_lowercase().chars().collect(); score_folded(&q, text)}
// The walk both scorers share: the query arrives already folded, the text// folds here. fuzzy_score and search call this, which is what keeps their// scores identical to each other and to step 1's JavaScript.fn score_folded(q: &[char], text: &str) -> i32 { let t = text.to_lowercase(); let mut score = 0; let mut qi = 0; let mut streak = 0; for tc in t.chars() { if qi == q.len() { break; } if tc == q[qi] { qi += 1; streak += 1; score += 2 + streak as i32; } else { streak = 0; } } if qi < q.len() { return -1; } score}
// The dataset once it has crossed: the corpus keeps the bytes exactly as they// arrived — one string, one item per line, title and description split by a// tab — and ranges records where each item's two fields sit inside it.static DATASET: OnceLock<(String, Vec<[u32; 4]>)> = OnceLock::new();
// Called once. Taking the corpus as a String means Rust owns the bytes the// glue just copied in, so they are never copied again: every later scan reads// them in place. JavaScript reaches the same bytes through a view —// check-search.mjs shows the view and the one thing that invalidates it.#[wasm_bindgen]pub fn load_dataset(corpus: String) -> usize { let mut offset = 0usize; let mut ranges = Vec::new(); for line in corpus.split('\n') { if let Some((title, description)) = line.split_once('\t') { ranges.push([ offset as u32, // title start (offset + title.len()) as u32, // title end (offset + title.len() + 1) as u32, // description start (offset + title.len() + 1 + description.len()) as u32, // description end ]); } offset += line.len() + 1; } let n = ranges.len(); let _ = DATASET.set((corpus, ranges)); // a second call is ignored n}
// One crossing per keystroke: the query in, packed index/score pairs out.// The titles never cross back — JavaScript kept its own copy for display.#[wasm_bindgen]pub fn search(query: &str, limit: usize) -> Vec<u32> { let Some((corpus, ranges)) = DATASET.get() else { panic!("search called before load_dataset"); }; let q: Vec<char> = query.to_lowercase().chars().collect();
let mut hits: Vec<(i32, u32)> = Vec::new(); // (score, item index) for (index, &[ts, te, ds, de]) in ranges.iter().enumerate() { let score = score_folded(&q, &corpus[ts as usize..te as usize]) .max(score_folded(&q, &corpus[ds as usize..de as usize])); if score >= 0 { hits.push((score, index as u32)); } } // Stable sort, biggest score first: ties stay in index order, the same // order JavaScript's Array#sort keeps. hits.sort_by(|a, b| b.0.cmp(&a.0)); hits.truncate(limit);
let mut out = Vec::with_capacity(hits.len() * 2); for (score, index) in hits { out.push(index); out.push(score as u32); } out}Three details carry the design.
The parameter is String, not &str. A &str borrow dies when the call returns, so
keeping the bytes would mean copying them again into a String you own. Taking a String
adopts the buffer the glue already allocated on the way in — one copy, total. The difference is
visible in the linear memory, and it was measured while writing this: loading the same corpus
through a &str parameter that calls corpus.to_string() pushes memory.buffer.byteLength
from 1,114,112 to 19,988,480; the String parameter lands at 12,713,984. The extra
7,274,496 bytes are the second copy, allocator overhead included.
Offsets, not copies. ranges holds four u32 per item — where the title starts and ends,
where the description starts and ends — and every later scan slices the corpus in place.
u32 offsets hold for any corpus under 4 GB; this one is 7,222,211 bytes.
One dataset, loaded once. OnceLock keeps the corpus and the table together, so a second
load_dataset call changes nothing. Step 7 will load at startup and never again. Calling
search first is a trap with an unhelpful message — shown below.
Rebuild:
wasm-pack build --target web[INFO]: 🎯 Checking for the Wasm target...[INFO]: 🌀 Compiling to Wasm... Compiling fuzzy-search v0.1.0 (/Users/rango/www/hackingwithrust/code/react-wasm) Finished `release` profile [optimized] target(s) in 0.46s[INFO]: ⬇️ Installing wasm-bindgen...[INFO]: Optimizing wasm binaries with `wasm-opt`...[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended[INFO]: ✨ Done in 0.82s[INFO]: 📦 Your wasm pkg is ready to publish at /Users/rango/www/hackingwithrust/code/react-wasm/pkg.The path is this machine’s checkout; yours will show your own. The module grew from 38,238 to 47,539 bytes — the two new exports and their glue.
The logic, checked without a browser
Section titled “The logic, checked without a browser”Nothing in the new functions needs wasm. The same file compiles for your own machine, the attribute functions are ordinary functions there, and a three-item corpus exercises both paths — a hit, a miss, a single-hit query:
use std::sync::OnceLock;use wasm_bindgen::prelude::*;
static DATASET: OnceLock<(String, Vec<[u32; 4]>)> = OnceLock::new();
#[wasm_bindgen]pub fn load_dataset(corpus: String) -> usize { let mut offset = 0usize; let mut ranges = Vec::new(); for line in corpus.split('\n') { if let Some((title, description)) = line.split_once('\t') { ranges.push([ offset as u32, // title start (offset + title.len()) as u32, // title end (offset + title.len() + 1) as u32, // description start (offset + title.len() + 1 + description.len()) as u32, // description end ]); } offset += line.len() + 1; } let n = ranges.len(); let _ = DATASET.set((corpus, ranges)); // a second call is ignored n}
fn score_folded(q: &[char], text: &str) -> i32 { let t = text.to_lowercase(); let mut score = 0; let mut qi = 0; let mut streak = 0; for tc in t.chars() { if qi == q.len() { break; } if tc == q[qi] { qi += 1; streak += 1; score += 2 + streak as i32; } else { streak = 0; } } if qi < q.len() { return -1; } score}
#[wasm_bindgen]pub fn search(query: &str, limit: usize) -> Vec<u32> { let Some((corpus, ranges)) = DATASET.get() else { panic!("search called before load_dataset"); }; let q: Vec<char> = query.to_lowercase().chars().collect();
let mut hits: Vec<(i32, u32)> = Vec::new(); // (score, item index) for (index, &[ts, te, ds, de]) in ranges.iter().enumerate() { let score = score_folded(&q, &corpus[ts as usize..te as usize]) .max(score_folded(&q, &corpus[ds as usize..de as usize])); if score >= 0 { hits.push((score, index as u32)); } } // Stable sort, biggest score first: ties stay in index order, the same // order JavaScript's Array#sort keeps. hits.sort_by(|a, b| b.0.cmp(&a.0)); hits.truncate(limit);
let mut out = Vec::with_capacity(hits.len() * 2); for (score, index) in hits { out.push(index); out.push(score as u32); } out}
fn main() { // Three real items from the step 1 dataset, which is generated // deterministically — item 0, item 31 and item 62. let corpus = [ "steel bracket #0\tsteel bracket for damping, rated 100 kg, batch 9000", "brass pulley #31\tbrass pulley for damping, rated 131 kg, batch 9031", "carbon gear #62\tcarbon gear for mounting, rated 122 kg, batch 9062", ] .join("\n"); println!("loaded {} items", load_dataset(corpus)); println!("search(\"pulley\") -> {:?}", search("pulley", 20)); println!("search(\"steel clamp\") -> {:?}", search("steel clamp", 20)); println!("search(\"gear\") -> {:?}", search("gear", 20));}loaded 3 itemssearch("pulley") -> [1, 33]search("steel clamp") -> []search("gear") -> [2, 18]The results come back as packed pairs — index first, score second. "pulley" hits item 1,
brass pulley #31, at 33 points: the same score step 3’s table printed for that pair. There is
only one pulley in the corpus, so the list stops there; limit is 20 but three items cannot
fill it. "steel clamp" matches nothing and returns an empty vector — an honest miss, not an
error. "gear" hits item 2 at 18.
The real test runs against all 100,000 items, below.
What the JavaScript side sees
Section titled “What the JavaScript side sees”The TypeScript definitions gain two lines:
export function load_dataset(corpus: string): number;
export function search(query: string, limit: number): Uint32Array;Step 3’s type table gains a row: a returned Vec<u32> arrives as a Uint32Array. At the ABI
level, search takes three numbers — the query’s pointer and length, plus the limit — and
returns a pointer/length pair of its own:
readonly load_dataset: (a: number, b: number) => number; readonly search: (a: number, b: number, c: number) => [number, number];The glue for the two functions:
export function load_dataset(corpus) { const ptr0 = passStringToWasm0(corpus, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len0 = WASM_VECTOR_LEN; const ret = wasm.load_dataset(ptr0, len0); return ret >>> 0;}export function search(query, limit) { const ptr0 = passStringToWasm0(query, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); const len0 = WASM_VECTOR_LEN; const ret = wasm.search(ptr0, len0, limit); var v2 = getArrayU32FromWasm0(ret[0], ret[1]).slice(); wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); return v2;}load_dataset’s glue is one passStringToWasm0 — the single copy this step permits the corpus —
and no free afterwards, because Rust kept the buffer. search’s glue copies the packed pairs out
with .slice() and frees the wasm-side buffer. The slice matters: getArrayU32FromWasm0 builds
a view over wasm memory, and without the copy the caller would hold a window that the next
keystroke’s scan overwrites. Forty numbers per keystroke cross here — 160 bytes.
The trap for calling things in the wrong order is real but quiet. Run search before
load_dataset in a fresh process:
node --input-type=module -e "import { readFile } from 'node:fs/promises'; const m = await import('./pkg/fuzzy_search.js'); m.initSync({ module: await readFile('./pkg/fuzzy_search_bg.wasm') }); m.search('pulley', 20);"wasm://wasm/0002e6ce:1
RuntimeError: unreachable at wasm://wasm/0002e6ce:wasm-function[24]:0x5bb7 at wasm://wasm/0002e6ce:wasm-function[40]:0x6d04 at wasm://wasm/0002e6ce:wasm-function[45]:0x732b at Module.search (file:///Users/rango/www/hackingwithrust/code/react-wasm/pkg/fuzzy_search.js:55:22) at file:///Users/rango/www/hackingwithrust/code/react-wasm/[eval1]:1:167The panic! message never arrives. A Rust panic in a wasm module ends in a trap, and without a
panic hook the trap reaches JavaScript as RuntimeError: unreachable — the wasm addresses are
this build’s, and the frames name the glue, not the cause. Load before you search.
The view
Section titled “The view”initSync returns the raw module exports, and memory is one of them: a WebAssembly.Memory
whose .buffer is the linear memory itself. A Uint8Array over that buffer is the zero-copy
road — the same bytes the Rust side scans, readable from JavaScript without a function call.
check-search.mjs, a new file next to Cargo.toml, demonstrates the view and then puts the
whole design on trial against step 1’s JavaScript search. First half:
// Compares the wasm search — dataset loaded once into wasm memory, one// crossing per keystroke — against the all-JavaScript search from step 1,// and shows the zero-copy view of wasm memory the loaded corpus leaves// behind. Run from react-wasm/ after `wasm-pack build --target web`://// node check-search.mjs//// The dataset is the step 1 generator, deterministic, so every reader works// through the same items and the byte offsets below are the same too.import { readFile } from 'node:fs/promises';
const wasm = await import('./pkg/fuzzy_search.js');// initSync returns the raw exports, and memory is on that object, not on// the module namespace.const instance = wasm.initSync({ module: await readFile('./pkg/fuzzy_search_bg.wasm') });const memory = instance.memory;
// The dataset: 100,000 catalogue items, exactly as step 1 generated them.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)}`, });}
// The corpus format load_dataset expects: one line per item, title and// description separated by a tab.const corpus = items.map((item) => `${item.title}\t${item.description}`).join('\n');
// A view created before the corpus exists...const before = memory.buffer.byteLength;const stale = new Uint8Array(memory.buffer);// ...does not survive the growth that loading 6 MB causes.const n = wasm.load_dataset(corpus);const after = memory.buffer.byteLength;
console.log(`loaded ${n} items, corpus ${corpus.length.toLocaleString('en-US')} bytes`);console.log(`memory: ${before.toLocaleString('en-US')} -> ${after.toLocaleString('en-US')} bytes`);console.log(`the view made before loading: length ${stale.length}`);
// A fresh view over the grown buffer. Nothing was copied to make it: it// points into wasm memory, the same bytes the Rust side scans.const view = new Uint8Array(memory.buffer);
// The corpus is findable in the view without a single function call. Scan// for a real description, then decode the line it sits on, straight from// wasm memory.const needle = new TextEncoder().encode('nylon pulley for tension');let at = -1;outer: for (let i = 0; i <= view.length - needle.length; i++) { for (let j = 0; j < needle.length; j++) { if (view[i + j] !== needle[j]) continue outer; } at = i; break;}let lineEnd = at;while (view[lineEnd] !== 10) lineEnd++; // the byte after the newline (10) ends the lineconsole.log(`"nylon pulley for tension" first in wasm memory at byte ${at.toLocaleString('en-US')}`);console.log(`decoded from the view: "${new TextDecoder().decode(view.subarray(at, lineEnd))}"`);Second half — the parity check, same file:
// The all-JavaScript search from step 1, for comparison.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;}
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: the query crosses once, packed index/score pairs come// back, and the titles are looked up in the JavaScript copy for display.function searchWasm(query) { const flat = wasm.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;}
const queries = ['bracket', 'steel bracket', 'nylon pulley for tension'];let mismatches = 0;for (const q of queries) { const js = searchJS(q); const ws = searchWasm(q); const same = js.length === ws.length && js.every((h, i) => h.title === ws[i].title && h.score === ws[i].score); if (!same) mismatches++; console.log( `"${q}": ${ws.length} hits, top "${ws[0].title}" score ${ws[0].score}${same ? '' : ' -- MISMATCH'}`, );}console.log(mismatches === 0 ? 'all top-20 lists agree' : `${mismatches} mismatches`);node check-search.mjsloaded 100000 items, corpus 7,222,211 bytesmemory: 1,114,112 -> 12,713,984 bytesthe view made before loading: length 0"nylon pulley for tension" first in wasm memory at byte 1,133,197decoded from the view: "nylon pulley for tension, rated 133 kg, batch 9273""bracket": 20 hits, top "steel bracket #0" score 42"steel bracket": 20 hits, top "steel bracket #0" score 117"nylon pulley for tension": 20 hits, top "nylon pulley #273" score 348all top-20 lists agreeRead the output in three groups.
The size of the once. The corpus is 7,222,211 bytes — item 0’s title through item 99,999’s description, with 199,999 separator bytes between them. The 7,022,212 characters step 4 counted being folded away per keystroke were exactly these titles and descriptions; the separators account for the difference. Memory grows from 17 pages of 64 KiB to 194, and the byte offsets here come from the pinned wasm-bindgen 0.2.129 and the deterministic corpus — a different wasm-bindgen can shift them, the agreement at the bottom does not shift.
The view, and the one thing that kills it. The stale view was created before the corpus
existed, and after load_dataset grew the memory it reports length 0: growing a wasm memory
detaches the old buffer, and every view on it goes dead — reads return nothing, and there is no
error, just a length of zero. This is why the glue re-checks its own cached view on every array
return:
let cachedUint32ArrayMemory0 = null;function getUint32ArrayMemory0() { if (cachedUint32ArrayMemory0 === null || cachedUint32ArrayMemory0.byteLength === 0) { cachedUint32ArrayMemory0 = new Uint32Array(wasm.memory.buffer); } return cachedUint32ArrayMemory0;}byteLength === 0 is the detached-buffer signature, and the rule for your own views is the
same one the glue follows: build them after the last allocation that could grow memory. Once the
dataset is loaded, nothing in this design grows the heap again, and the view is stable. The proof
it sees the real bytes is the last two lines: "nylon pulley for tension" sits at byte
1,133,197 — item 273’s description — and decoding forward to the newline, straight from the
view, reproduces the description character for character. No function call, no copy.
The parity. For all three queries the wasm top-20 is the JavaScript top-20, titles and scores, including the order within ties — that is what the stable sort on each side buys. Item 273 tops the long query at 348 points, the same number step 3’s table printed for that pair when the text crossed on every call.
What one crossing buys
Section titled “What one crossing buys”Before this step, a keystroke meant 200,000 fuzzy_score calls, each copying its text argument
into wasm memory: 7,022,212 bytes of titles and descriptions, plus the query 200,000 times over.
After it, a keystroke copies one query string in and forty numbers out. The 7.2 MB crossed once,
at startup.
What it does not buy: the scan itself. Seven million characters still get walked one by one, now inside wasm, and whether wasm walks them faster than V8 does is a measurement question — the kind step 6 answers properly, with warm-up handled and medians instead of single runs.
What the boundary still refuses
Section titled “What the boundary still refuses”The view exists on the JavaScript side because wasm-bindgen will not hand a borrowed slice back in either direction. You might try to export the corpus directly:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn arena() -> &'static [u8] { Box::leak(vec![1u8, 2, 3].into_boxed_slice())}error: cannot return a borrowed ref with #[wasm_bindgen] --> src/lib.rs:4:19 |4 | pub fn arena() -> &'static [u8] { | ^^^^^^^^^^^^^
error: could not compile `fuzzy-search` (lib) due to 1 previous errorEven a 'static slice is refused — the macro’s line from step 3, unchanged for &[u8]. A
returned slice would point into wasm memory with no lifetime JavaScript could honour, so the
only road to zero-copy sharing is the one this step took: keep the bytes on the Rust side, and
let JavaScript hold the view.
Where the code stands
Section titled “Where the code stands”This step’s end state is tagged wasm-ch05 in
the examples repository. src/lib.rs grows the
dataset half and check-search.mjs is new; Cargo.toml, check.mjs and profile.mjs are
exactly as step 4 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-ch05wasm-pack build --target webnode check.mjsnode check-search.mjsStep 6, Benchmarking WebAssembly honestly, measures what this step bought — with JIT warm-up handled and medians instead of single runs, because most published wasm numbers are wrong in exactly that way. The track overview keeps the full list current as articles land.