The bridge between Rust and the browser
You have a React component that needs to parse a massive CSV file. The JavaScript parser blocks the UI for two seconds while the main thread chokes on string manipulation. You wrote a Rust parser that finishes in 50 milliseconds. Now you need to wire that Rust code into the React component so the user never sees the spinner, without fighting the build system for three days.
WebAssembly (WASM) is the binary format that lets browsers execute Rust code. Rust compiles to WASM efficiently. The browser can load and run the WASM module. The catch is that WASM does not speak JavaScript. It speaks linear memory and function indices. You cannot pass a JavaScript object directly into a WASM function. You cannot return a Rust string and expect React to render it.
wasm-bindgen is the translator. It generates JavaScript glue code that wraps the WASM module, handles memory conversion, and exposes safe Rust functions as JavaScript functions. wasm-pack is the orchestrator. It runs wasm-bindgen, compiles the Rust crate, and packages the output for the web.
How the bridge works
Rust manages its own heap inside the WASM module. This heap is a contiguous block of memory called linear memory. When you call a Rust function from JavaScript, wasm-bindgen copies the arguments into linear memory, calls the WASM function, and copies the result back.
If you pass a string, wasm-bindgen allocates space in linear memory, copies the bytes, and passes a pointer and length to Rust. Rust sees a &str. When the function returns, Rust frees the temporary allocation if needed. If you return a String, wasm-bindgen copies the bytes back to a JavaScript string and frees the Rust allocation.
This copying is intentional. It keeps the memory model simple. JavaScript and Rust have different garbage collectors. Sharing memory directly requires careful lifetime management. wasm-bindgen hides the complexity by copying data at the boundary. For small data, the copy cost is negligible. For large buffers, you can share memory using Uint8Array views, but that requires explicit handling.
use wasm_bindgen::prelude::*;
/// Add two integers and return the sum.
/// This function demonstrates the basic export pattern.
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Greet a user by name.
/// wasm-bindgen converts the JavaScript string to a Rust &str automatically.
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
The #[wasm_bindgen] attribute marks functions for export. It tells the compiler to generate the metadata needed for the JavaScript bindings. Without this attribute, the function remains internal to the WASM module and is invisible to JavaScript.
Building the module
Install wasm-pack globally. It handles the toolchain setup and build orchestration.
cargo install wasm-pack
Create a Rust library crate. Libraries export functions; binaries do not.
cargo new --lib my-wasm-lib
cd my-wasm-lib
Add dependencies to Cargo.toml. wasm-bindgen provides the macro and runtime. js-sys gives access to JavaScript built-ins. web-sys gives access to browser APIs.
[package]
name = "my-wasm-lib"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
The crate-type = ["cdylib"] line is mandatory. It tells Rust to compile the crate as a dynamic library, which is the format required for WASM. Without it, wasm-pack will fail.
Build the module. Use --target bundler for modern frontend projects. This target generates JavaScript that uses import statements to load the WASM file, which works with Vite, Webpack, and esbuild.
wasm-pack build --target bundler
The command produces a pkg/ directory. Inside you find:
my_wasm_lib.js: The JavaScript bindings. Import this file in your frontend.my_wasm_lib_bg.wasm: The compiled WASM binary. The JS file loads this automatically.package.json: Metadata for npm. Useful if you publish the library.
Copy the pkg/ folder into your frontend project. Place it in src/ or public/ depending on your bundler configuration. For Vite and Webpack, src/ works best because the bundler can resolve the WASM file path.
Calling from React
React components mount asynchronously. The WASM module also loads asynchronously. You must wait for the WASM module to initialize before calling any exported functions.
The generated JavaScript file exports an init function. This function returns a promise that resolves when the WASM module is loaded and ready. Call init once, typically in a top-level script or a layout component, and reuse the exported functions afterward.
import { useState, useEffect } from 'react';
import init, { add, greet } from './pkg/my_wasm_lib.js';
function Calculator() {
const [result, setResult] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Initialize the WASM module once.
// The promise resolves when the binary is loaded and instantiated.
init().then(() => {
setLoading(false);
// Call exported functions after initialization.
setResult(add(2, 3));
});
}, []);
if (loading) {
return <div>Loading...</div>;
}
return (
<div>
<p>Result: {result}</p>
<p>{greet("Developer")}</p>
</div>
);
}
The useEffect hook runs after the component mounts. It calls init and sets the loading state to false when the promise resolves. The exported functions add and greet are available immediately after init resolves.
Treat init as a gate. Your component is dead weight until the promise resolves. Do not call exported functions before init completes, or you will get a runtime error.
Passing complex data
React applications often exchange JSON. Rust structs do not serialize to JSON automatically in the WASM context. Use serde-wasm-bindgen to bridge the gap. It serializes Rust structs to JSON strings and deserializes JSON strings back to Rust structs, handling the memory conversion safely.
Add serde and serde-wasm-bindgen to Cargo.toml.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde-wasm-bindgen = "0.6"
Define a Rust struct and derive Serialize and Deserialize. Export a function that accepts a JSON string and returns a JSON string.
use serde::{Serialize, Deserialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
struct Config {
threshold: f64,
name: String,
}
/// Process configuration and return a result.
/// Accepts a JSON string, deserializes to Config, and returns a JSON string.
#[wasm_bindgen]
pub fn process_config(config_json: &str) -> String {
// Deserialize the JSON string into a Rust struct.
let config: Config = serde_wasm_bindgen::from_str(config_json).unwrap();
// Perform logic.
let result = format!("Processed {} with threshold {}", config.name, config.threshold);
// Serialize the result back to JSON.
serde_wasm_bindgen::to_string(&result).unwrap()
}
In JavaScript, pass a JSON string and parse the result.
import init, { processConfig } from './pkg/my_wasm_lib.js';
async function run() {
await init();
const config = { threshold: 0.5, name: "Test" };
const resultJson = processConfig(JSON.stringify(config));
const result = JSON.parse(resultJson);
console.log(result);
}
This pattern keeps the boundary clean. JavaScript handles JSON natively. Rust handles structs natively. serde-wasm-bindgen handles the conversion. You avoid manual field mapping and memory errors.
Convention aside: The community prefers serde-wasm-bindgen over raw serde_json for WASM boundaries. serde_json works, but serde-wasm-bindgen integrates better with wasm-bindgen's memory management and handles edge cases like circular references more gracefully.
Handling large buffers
Copying large buffers across the boundary is expensive. If you process image data or audio samples, you want to share memory instead of copying. Use js_sys::Uint8Array to pass a view of JavaScript memory to Rust.
wasm-bindgen can accept a &[u8] slice from a Uint8Array. The slice borrows the JavaScript buffer. Rust can read and write the data in place. No copy occurs.
use wasm_bindgen::prelude::*;
use js_sys::Uint8Array;
/// Invert pixel values in place.
/// Accepts a Uint8Array and modifies the buffer directly.
#[wasm_bindgen]
pub fn invert_pixels(pixels: &mut Uint8Array) {
// Get a mutable slice of the buffer.
// The slice borrows the JavaScript memory.
let data: &mut [u8] = pixels.as_mut_slice();
for pixel in data.iter_mut() {
*pixel = 255 - *pixel;
}
}
In JavaScript, create a Uint8Array and pass it to the function. The buffer is modified in place.
import init, { invertPixels } from './pkg/my_wasm_lib.js';
async function run() {
await init();
const pixels = new Uint8Array([100, 150, 200, 255]);
invertPixels(pixels);
console.log(pixels); // [155, 105, 55, 0]
}
This approach is essential for performance-critical code. If you pass a Vec<u8> instead, wasm-bindgen copies the data into Rust's heap, processes it, and copies it back. The copy overhead dominates the execution time for large buffers.
Memory in WASM is a shared pool. If you leak pointers, the browser tab dies. Always return owned types like Vec<T> or Box<T> so wasm-bindgen can free the memory. Never return raw pointers to JavaScript.
Pitfalls and errors
You will hit a panic that aborts the WASM module with no stack trace. Rust panics inside WASM do not print to the console by default. The browser shows a silent crash or a generic abort message. Install console_error_panic_hook to catch panics and print a readable stack trace.
Add console_error_panic_hook to Cargo.toml.
[dependencies]
console_error_panic_hook = "0.1"
Install the hook in your initialization code.
use wasm_bindgen::prelude::*;
/// Initialize panic handling.
/// Call this once during startup to enable stack traces.
#[wasm_bindgen(start)]
pub fn main() {
console_error_panic_hook::set_once();
}
The #[wasm_bindgen(start)] attribute marks a function to run automatically when the module initializes. This is the standard place for setup code like panic hooks.
You will see TypeError: init is not a function if you import the wrong file. The generated package contains multiple files. Import the .js file, not the .wasm file. The .wasm file is loaded by the .js file.
You will encounter E0277 (trait bound not satisfied) if you try to export a function that returns a type wasm-bindgen does not support. wasm-bindgen supports primitives, String, Vec<T>, Box<T>, and types marked with #[wasm_bindgen]. Custom structs must be exported explicitly.
#[wasm_bindgen]
pub struct MyStruct {
value: i32,
}
#[wasm_bindgen]
impl MyStruct {
#[wasm_bindgen(constructor)]
pub fn new(value: i32) -> MyStruct {
MyStruct { value }
}
}
The #[wasm_bindgen] attribute on the struct and impl block is required. Without it, the type is opaque to JavaScript. You cannot pass or return it across the boundary.
Install the panic hook. A silent abort is worse than a slow app.
Decision matrix
Use wasm-pack build --target bundler when your frontend uses Vite, Webpack, or esbuild. The bundler handles the WASM file loading and path resolution automatically.
Use wasm-pack build --target web when you are loading the module via a raw <script> tag or a simple fetch without a bundler. This target generates a self-contained JS file that fetches the WASM blob using a relative URL.
Use serde-wasm-bindgen when you need to exchange complex data structures between Rust and JavaScript. It handles serialization and memory conversion safely.
Use js-sys when you need to interact with JavaScript built-ins like Array, Date, or Math without touching the DOM.
Use web-sys when you need to manipulate the DOM, handle events, or access browser APIs like fetch or localStorage.
Use wasm-bindgen-futures when your Rust code needs to await a JavaScript promise. It bridges Rust async/await with JavaScript promises.
Convention wins here. Stick to wasm-pack and --target bundler unless you have a reason not to. The ecosystem assumes this setup. Deviating requires manual wiring that breaks easily.