You built a tool, now the browser needs it
You wrote a password strength checker in Rust. It runs in milliseconds. You want to embed it in a web form so users get instant feedback without sending data to a server. Or you're building a puzzle game where the logic must run client-side to prevent cheating. You try cargo build and get a binary that only runs on your machine. The browser needs WebAssembly.
Rust compiles to WebAssembly cleanly. The process involves adding a target to your toolchain and compiling with a specific flag. The output is a .wasm file that browsers can load and execute at near-native speed.
WebAssembly and the target triple
WebAssembly is a binary instruction format designed to run in a sandboxed environment. It is not JavaScript. It is a compiled format that the browser's engine can decode and execute efficiently. Rust treats WebAssembly like any other platform: you select a target triple and compile.
The standard target for browser-based WebAssembly is wasm32-unknown-unknown. The triple breaks down as follows:
wasm32: The architecture is WebAssembly with 32-bit addressing.unknown-unknown: There is no operating system and no environment. This is raw WebAssembly with no system calls.
This target produces a binary that contains only your code and the Rust standard library subset that works without an OS. You cannot read files, spawn threads, or access the network directly. The browser provides those capabilities through JavaScript APIs, and you bridge the gap using interop tools.
The minimal compile
To compile for WebAssembly, you need the target installed. Rustup manages targets separately from the main toolchain.
rustup target add wasm32-unknown-unknown
Create a library crate. WebAssembly modules are libraries, not executables. The browser loads the module and calls functions from it.
cargo new --lib my-wasm-app
cd my-wasm-app
Write a function in src/lib.rs.
/// Calculate the Fibonacci number at index n.
pub fn fib(n: u32) -> u32 {
if n < 2 {
return n;
}
let mut a = 0;
let mut b = 1;
// Iterate from 2 to n inclusive.
for _ in 2..=n {
let temp = a + b;
a = b;
b = temp;
}
b
}
Compile with the target flag. Always use --release for WebAssembly. Debug builds include metadata that bloats the binary size and disables optimizations that the browser needs for performance.
cargo build --target wasm32-unknown-unknown --release
The output lands in target/wasm32-unknown-unknown/release/my_wasm_app.wasm. This file is a raw WebAssembly module. You can load it in the browser using the WebAssembly.instantiate API, but calling functions requires manual glue code to handle memory and type conversions. Raw WASM is a black box to JavaScript.
The realistic workflow: wasm-bindgen and wasm-pack
Writing glue code by hand is error-prone. The community standard is wasm-bindgen, a tool that generates JavaScript bindings for your Rust code. wasm-pack is the command-line wrapper that orchestrates the build, runs wasm-bindgen, and packages the output for npm or the browser.
Add wasm-bindgen to your dependencies.
[dependencies]
wasm-bindgen = "0.2"
Mark functions for export using the #[wasm_bindgen] macro. This tells the code generator to expose the function to JavaScript.
use wasm_bindgen::prelude::*;
/// Export this function to JavaScript.
#[wasm_bindgen]
pub fn calculate_fib(n: u32) -> u32 {
if n < 2 {
return n;
}
let mut a = 0;
let mut b = 1;
for _ in 2..=n {
let temp = a + b;
a = b;
b = temp;
}
b
}
Install wasm-pack if you haven't already.
cargo install wasm-pack
Build the project.
wasm-pack build --target web
The --target web flag tells wasm-pack to generate bindings optimized for the browser. The output appears in a pkg/ directory. This directory contains the .wasm file, a JavaScript file with the glue code, and a package.json. You can import the JavaScript file directly in your frontend code.
import init, { calculate_fib } from './pkg/my_wasm_app.js';
async function main() {
await init();
console.log(calculate_fib(10)); // 55
}
main();
The init function loads the WebAssembly module and sets up the memory. Once initialized, you can call calculate_fib like a normal JavaScript function. wasm-bindgen handles the conversion of arguments and return values automatically.
Convention: The panic hook
When Rust code panics in WebAssembly, the default behavior is to crash the module with a cryptic RuntimeError: unreachable. This gives you no stack trace. The community convention is to install console_error_panic_hook. It catches panics and prints the Rust backtrace to the browser console.
Add the dependency.
[dependencies]
console_error_panic_hook = "0.1"
Initialize it in your library. Use the #[wasm_bindgen(start)] macro to run code when the module loads.
use wasm_bindgen::prelude::*;
/// Initialize the panic hook so Rust panics print to the console.
#[wasm_bindgen(start)]
pub fn init_panic_hook() {
console_error_panic_hook::set_once();
}
Now, if your code panics, the browser console shows the line number and context. Debugging becomes possible.
Pitfalls: Size, std, and memory
WebAssembly binaries are downloaded over the network. Size matters. A large binary slows down page loads and increases memory usage.
The standard library is compiled into your binary by default. For small projects, std can be larger than your code. If you don't need std, switch to no_std. This requires more work but can shrink the binary significantly.
[dependencies]
wasm-bindgen = { version = "0.2", default-features = false }
If you keep std, optimize the build profile. Add these settings to your Cargo.toml.
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
opt-level = "z" optimizes for size. lto = true enables link-time optimization, which allows the compiler to eliminate dead code across crate boundaries. codegen-units = 1 forces a single compilation unit, improving optimization at the cost of build time.
The standard library on wasm32-unknown-unknown is limited. System calls are missing. You cannot use std::fs, std::net, or std::thread. Attempting to use these results in compile errors because the target does not provide the implementations.
error[E0433]: failed to resolve: could not find `fs` in `std`
If you need file access or networking, use JavaScript APIs through wasm-bindgen. The web-sys crate provides bindings to the browser's DOM and Web APIs.
Memory in WebAssembly is linear. It is a contiguous block of bytes that grows as needed. Rust's allocator manages this memory. The default allocator works, but wee_alloc is smaller and often preferred for size-sensitive applications.
[dependencies]
wee_alloc = "0.4"
Replace the global allocator.
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
Don't ship debug builds to the web. The size difference is embarrassing. A debug build can be ten times larger than a release build.
Decision matrix
Use wasm32-unknown-unknown with plain cargo when you need a raw .wasm binary for a custom runtime or a framework that manages its own glue code.
Use wasm-pack with wasm-bindgen when you are building for the browser or Node.js and need seamless JavaScript interop.
Use wasm32-wasip1 when your code requires system calls like file I/O and you are deploying to a WASI-compatible environment.
Reach for wasm-opt when your binary size is too large and you need aggressive optimization beyond what cargo provides.
If you're talking to JavaScript, use wasm-pack. Fighting the glue code manually is a rabbit hole with no bottom.