Skip to content
Select theme

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.

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:

src/lib.rs
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:

Terminal window
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.

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:

loaded 3 items
search("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.

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:

Terminal window
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:167

The 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.

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 line
console.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`);
Terminal window
node check-search.mjs
loaded 100000 items, corpus 7,222,211 bytes
memory: 1,114,112 -> 12,713,984 bytes
the view made before loading: length 0
"nylon pulley for tension" first in wasm memory at byte 1,133,197
decoded 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 348
all top-20 lists agree

Read 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.

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.

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 error

Even 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.

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:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/react-wasm
git checkout wasm-ch05
wasm-pack build --target web
node check.mjs
node check-search.mjs

Step 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.