Skip to content
Select theme

Calling Rust from JavaScript

Step 3 of 8, and it crosses the boundary for the first time. Step 2 left a pkg/ directory whose entire surface was init machinery — ready() compiled, ran, and appeared nowhere in the exports. This step marks four functions with #[wasm_bindgen], rebuilds, and calls them from Node: two number-shaped functions, one string function, and the real target — a Rust port of step 1’s fuzzyScore that produces the same score for every input, which a script proves call by call.

Start from the step 2 end state — git checkout wasm-ch02 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:

[package]
name = "fuzzy-search"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"

The whole of src/lib.rs gets rewritten. Show it in full, because the attribute is doing more work than its size suggests:

src/lib.rs
use wasm_bindgen::prelude::*;
// Numbers cross the boundary as plain JavaScript numbers.
#[wasm_bindgen]
pub fn add(left: i32, right: i32) -> i32 {
left + right
}
// 64-bit integers do not fit in a JavaScript number, so they cross as BigInt.
#[wasm_bindgen]
pub fn expand(n: u64) -> u64 {
n * 1_000_000_000
}
// A &str argument is copied into wasm memory; the returned String is a fresh
// allocation that is copied back out. Nothing is shared — step 5 gets to that.
#[wasm_bindgen]
pub fn shout(text: &str) -> String {
text.to_uppercase() + "!!!"
}
// 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();
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] tells the macro to generate the glue for a function — the wrapper code that turns JavaScript values into wasm ABI values and back. Two rules the macro enforces itself: exported functions must be pub, and they cannot return borrowed references. The first gets its own error if you forget:

error: can only #[wasm_bindgen] public functions

The second gets a section of its own below. fuzzy_score keeps the JS version’s -1 sentinel for “no match” on purpose: with the same inputs and the same encoding of misses, the two implementations can be diffed mechanically, without translation in the middle.

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.14s
[INFO]: ⬇️ Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: ✨ Done in 0.43s
[INFO]: 📦 Your wasm pkg is ready to publish at /Users/rango/www/hackingwithrust/code/react-wasm/pkg.

The path on the last line is this machine’s checkout; yours will show your own. One number worth noticing before anything else:

Terminal window
ls -l pkg/fuzzy_search_bg.wasm
-rw-r--r--@ 1 rango staff 38238 Oct 20 23:36 pkg/fuzzy_search_bg.wasm

Step 2’s crate, with nothing callable in it, weighed 362 bytes. Four exported functions and their glue take it to 38,238. The module pays for what it exports — a number to keep in mind when someone proposes moving every helper into wasm.

The generated TypeScript definitions are the contract, and this time they list real functions:

export function add(left: number, right: number): number;
export function expand(n: bigint): bigint;
export function fuzzy_score(query: string, text: string): number;
export function shout(text: string): string;

The name in JavaScript is the name in Rust — fuzzy_score comes over with its underscore, not renamed to fuzzyScore. The type mapping for everything this track needs:

Rust JavaScript
i32, u32, f64 number
u64, i64 bigint
bool boolean
&str, String string

The definitions file also shows the raw wasm exports, and that view is the honest one:

readonly memory: WebAssembly.Memory;
readonly add: (a: number, b: number) => number;
readonly expand: (a: bigint) => bigint;
readonly fuzzy_score: (a: number, b: number, c: number, d: number) => number;
readonly shout: (a: number, b: number) => [number, number];

At the ABI level there are no strings. fuzzy_score takes four numbers — two (pointer, length) pairs, one per string — and returns a number. shout returns a pair of numbers too. The string in the TypeScript signature is a promise the glue keeps by copying.

The bigint row bites immediately. Call expand with a plain number and Node refuses:

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.expand(12345);"
TypeError: Cannot convert 12345 to a BigInt
at Module.expand (file:///Users/rango/www/hackingwithrust/code/react-wasm/pkg/fuzzy_search.js:18:22)
at file:///Users/rango/www/hackingwithrust/code/react-wasm/[eval1]:1:167

The stack frames below the first line name your own paths; trimmed here. 12,345 fits comfortably in a JavaScript number — the boundary does not care. The type is the type: a u64 crossing is a BigInt, and the glue rejects anything else rather than convert it silently.

Nothing in fuzzy_score needs wasm. Compiled for your own machine, the same function prints the same scores — which is also the fastest way to check the algorithm before involving a browser:

bracket -> 42
steel bracket -> 117
nylon pulley for tension -> 348
pulley -> 33
gear -> -1
brak -> 15

Both implementations fold case and walk the text one unit at a time — the JavaScript one in UTF-16 code units, the Rust one in chars. The catalogue is ASCII, so those are the same walk; that agreement is what the next section leans on.

Assertions about parity are cheap; this step ships one that runs. check.mjs, a new file next to Cargo.toml, drives the wasm module from Node and compares it against the JavaScript original copied from step 1:

// Compares the JavaScript fuzzyScore from step 1 with the Rust fuzzy_score
// in pkg/. Run from react-wasm/ after `wasm-pack build --target web`:
//
// node check.mjs
//
// Every case is a real item from the step 1 dataset, which is generated
// deterministically, so these strings are the same on every machine.
import { readFile } from 'node:fs/promises';
const wasm = await import('./pkg/fuzzy_search.js');
wasm.initSync({ module: await readFile('./pkg/fuzzy_search_bg.wasm') });
// The simple functions first — watch the types come back. Note the `n` on
// expand's argument: u64 arrives as BigInt, and a plain number is rejected.
console.log('add(2, 3) ->', wasm.add(2, 3));
console.log('expand(12_345n) ->', wasm.expand(12_345n));
console.log('shout("hello") ->', wasm.shout('hello'));
// The JavaScript original, copied from step 1.
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;
}
const cases = [
['bracket', 'steel bracket #0'],
['steel bracket', 'steel bracket #0'],
['nylon pulley for tension', 'nylon pulley for tension, rated 133 kg, batch 9273'],
['pulley', 'brass pulley #31'],
['gear', 'steel bracket #0'],
['brak', 'steel bracket #0'],
];
let mismatches = 0;
for (const [query, text] of cases) {
const js = fuzzyScore(query, text);
const rust = wasm.fuzzy_score(query, text);
if (js !== rust) mismatches++;
console.log(
`${JSON.stringify(query).padEnd(28)} js ${String(js).padStart(3)} rust ${String(rust).padStart(3)}${js === rust ? '' : ' <-- MISMATCH'}`,
);
}
console.log(mismatches === 0 ? 'all scores agree' : `${mismatches} mismatches`);

Run it:

Terminal window
node check.mjs
add(2, 3) -> 5
expand(12_345n) -> 12345000000000n
shout("hello") -> HELLO!!!
"bracket" js 42 rust 42
"steel bracket" js 117 rust 117
"nylon pulley for tension" js 348 rust 348
"pulley" js 33 rust 33
"gear" js -1 rust -1
"brak" js 15 rust 15
all scores agree

The three simple functions come back with the types the definitions promised — 12345000000000n is a BigInt, printed with its n. The six scoring cases include a miss on both sides, and every js/rust pair agrees. The step 1 dataset is generated deterministically, so these case strings are the same items on your machine.

The boundary refuses one direction outright: borrowed returns. A function that finds the first word looks harmless:

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn first_word(text: &str) -> &str {
text.split_whitespace().next().unwrap_or("")
}

Build it and the macro stops the compile before cargo gets anywhere:

error: cannot return a borrowed ref with #[wasm_bindgen]
--> src/lib.rs:4:34
|
4 | pub fn first_word(text: &str) -> &str {
| ^^^^
error: could not compile `fuzzy-search` (lib) due to 1 previous error

The line number is where the function sits in the file, so yours will vary. The reason is the memory model: the &str would point into the wasm module’s memory, and there is no lifetime the JavaScript side could honour — the caller’s string may be gone by the time the glue reads the pointer. The fix is to hand back something owned:

use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn first_word(text: &str) -> String {
text.split_whitespace().next().unwrap_or("").to_string()
}

The return value is a fresh String the glue copies out as a JavaScript string.

The generated glue makes the price visible. Here is shout as wasm-bindgen wrote it, in pkg/fuzzy_search.js:

export function shout(text) {
let deferred2_0;
let deferred2_1;
try {
const ptr0 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.shout(ptr0, len0);
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}

Count the work that is not the function itself: passStringToWasm0 UTF-8-encodes the string and copies the bytes into wasm memory; the Rust side allocates a new String for the result; the glue decodes that buffer into a fresh JavaScript string; and __wbindgen_free releases the Rust allocation. Two copies and an allocation in each direction, per call. Compare add, whose glue is the call and nothing else:

export function add(left, right) {
const ret = wasm.add(left, right);
return ret;
}

Numbers cross in registers. Strings cross through memory, twice.

This is the design pressure for the rest of the track. The real app scores 100,000 items on every keystroke, title and description each — 200,000 fuzzy_score calls, each copying its query and text into wasm memory. The algorithm is the same three lines either way; the copies are the new cost. Step 4 profiles before optimising anything, and step 5 moves the dataset into wasm memory so the boundary gets crossed once per keystroke instead of 200,000 times.

This step’s end state is tagged wasm-ch03 in the examples repository. src/lib.rs is rewritten and check.mjs is new; Cargo.toml is unchanged from step 2. Checking out the tag puts your tree exactly where this article left it:

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

The boundary works and its price is measured. Step 4 picks the real function to optimise — with a profiler, not a hunch, because some of the 40 ms is fixable in JavaScript alone and some is not worth fixing at all. The track overview keeps the full list current as articles land.