When Rust meets the browser
You spent the morning writing a high-performance image filter in Rust. It compiles, it runs, it's blazing fast. Now you need to drop it into a React component or a vanilla JS script so users can run it in their browser. You run cargo build and get a binary that only runs on your machine. The browser doesn't care about your ELF or Mach-O executable. It wants WebAssembly.
You need a tool that bridges the gap between the Rust toolchain and the JavaScript ecosystem. It has to compile your code to .wasm, generate the JavaScript glue code that lets JS call Rust functions, handle memory management across the boundary, and package everything up so a JS build tool can consume it. Doing this manually involves wrestling with wasm-bindgen flags, wasm-opt optimization passes, and npm package structures. wasm-pack automates the entire pipeline. It turns your Rust library into a drop-in JavaScript package with a single command.
What wasm-pack actually does
wasm-pack is a command-line tool that orchestrates the build process for WebAssembly targets. It sits on top of wasm-bindgen, which generates the JavaScript bindings, and wasm-opt, which shrinks the final binary. When you run wasm-pack build, the tool invokes cargo with the wasm32-unknown-unknown target, processes the output through wasm-bindgen to create the JS wrapper and TypeScript definitions, runs wasm-opt for size and performance, and copies everything into a pkg/ directory structured like an npm package.
The tool also handles configuration details that trip up beginners. It ensures your Cargo.toml has the correct crate-type set to cdylib, which is required for producing a dynamic library that can be loaded as WASM. It manages the dependency on wasm-bindgen itself. You focus on writing Rust; wasm-pack handles the translation and packaging.
Treat wasm-pack as the bridge. Your Rust code stays Rust; the tool handles the translation.
Minimal setup and first build
Create a new library crate. You need a library, not a binary, because WebAssembly modules are imported, not executed directly.
cargo new --lib my-wasm-lib
cd my-wasm-lib
Add the wasm-bindgen dependency. This crate provides the attributes and types needed to expose Rust code to JavaScript.
# Cargo.toml
[dependencies]
wasm-bindgen = "0.2"
Write a simple function. The #[wasm_bindgen] attribute marks the function for export. Without this attribute, wasm-bindgen ignores the function, and it won't appear in the generated JavaScript.
// src/lib.rs
use wasm_bindgen::prelude::*;
/// Add two numbers together.
/// This function is exposed to JavaScript via wasm-bindgen.
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
Run the build command. The --target flag tells wasm-pack how to structure the output for your JavaScript environment.
wasm-pack build --target web
The command produces a pkg/ directory. Inside, you'll find my_wasm_lib.js, my_wasm_lib.d.ts, and my_wasm_lib_bg.wasm. The .js file contains the glue code. The .d.ts file provides TypeScript types. The .wasm file is the compiled WebAssembly module.
Add pkg/ to your .gitignore. The directory is generated output, not source code. Regenerating it is cheap; tracking it in version control causes merge conflicts and bloats the repository.
How the build pipeline works
When you run wasm-pack build, the tool executes a sequence of steps. Understanding this sequence helps you debug issues when things go wrong.
First, wasm-pack calls cargo build --target wasm32-unknown-unknown. This compiles your Rust code into a raw WebAssembly module. The wasm32-unknown-unknown target produces WASM without assuming a specific host environment. It strips out standard library features that don't exist in WASM, like threads and filesystem access.
Next, wasm-bindgen scans the .wasm file for items marked with #[wasm_bindgen]. It generates a JavaScript wrapper that handles type conversions, memory allocation, and function calls. It also produces TypeScript definition files. If you return a String from Rust, wasm-bindgen generates code to allocate memory for the string, copy the bytes, and return a JavaScript string. It handles the memory cleanup automatically.
Finally, wasm-opt runs over the .wasm file. This tool from the Binaryen toolkit optimizes the bytecode. It removes dead code, shrinks function sizes, and improves execution speed. The result is a smaller, faster module that loads quicker in the browser.
The output lands in pkg/. The structure matches an npm package, so you can publish it to npm or import it directly in your project.
Targets and output formats
The --target flag changes how wasm-pack structures the output. Pick the target that matches your JavaScript build setup.
Use --target web when you want to load the WASM module directly in a browser using a <script> tag. This target produces a single JavaScript file that fetches the .wasm file and exports the functions globally. It works without a bundler.
Use --target bundler when you are using a JavaScript bundler like webpack, Rollup, or Vite. This target produces files that integrate with the bundler's module system. The bundler can tree-shake unused code and optimize the final bundle. This is the most common target for modern web applications.
Use --target nodejs when you want to run the WASM module in Node.js. This target generates CommonJS or ESM exports that Node can import directly.
Use --target no-modules when you need a raw .wasm file without any JavaScript wrapper. This is useful for custom toolchains or non-JavaScript hosts.
Convention aside: --target bundler is the default choice for most projects. Modern JS tooling expects ES modules, and wasm-pack handles the interop correctly. Only use --target web if you are avoiding bundlers entirely.
Realistic example: Stateful objects and panic hooks
Functions are great, but real applications often need state. wasm-bindgen supports Rust structs with methods. You can create objects in JavaScript that hold Rust state.
// src/lib.rs
use wasm_bindgen::prelude::*;
/// A counter that maintains state across calls.
#[wasm_bindgen]
pub struct Counter {
value: i32,
}
#[wasm_bindgen]
impl Counter {
/// Create a new counter starting at zero.
/// The constructor attribute marks this as the JS constructor.
#[wasm_bindgen(constructor)]
pub fn new() -> Counter {
Counter { value: 0 }
}
/// Increment the counter and return the new value.
pub fn increment(&mut self) -> i32 {
self.value += 1;
self.value
}
/// Get the current value.
pub fn get_value(&self) -> i32 {
self.value
}
}
The #[wasm_bindgen(constructor)] attribute tells wasm-bindgen to expose new as the JavaScript constructor. In JS, you can write const counter = new Counter();.
There is a hidden trap in WASM development. Panics in Rust print to stdout. The browser has no stdout. If your Rust code panics, the browser stays silent. You'll see a frozen page with no error message. This wastes hours of debugging time.
Fix this by adding a panic hook. The console_error_panic_hook crate redirects panic messages to the browser console.
# Cargo.toml
[dependencies]
wasm-bindgen = "0.2"
console_error_panic_hook = "0.1"
// src/lib.rs
use wasm_bindgen::prelude::*;
/// Initialize the panic hook so panics print to the browser console.
/// The start attribute runs this function when the module loads.
#[wasm_bindgen(start)]
pub fn init_panic_hook() {
console_error_panic_hook::set_once();
}
The #[wasm_bindgen(start)] attribute ensures the hook runs before any other code. Now, if a panic occurs, you'll see the error in the developer console with a stack trace.
Convention aside: Always include console_error_panic_hook in your WASM crates. It's the standard way to get debuggable errors. The community expects it.
Pitfalls and compiler errors
WASM development has specific constraints. The browser is a sandbox. Rust's standard library assumes a full operating system. These assumptions clash.
You cannot use std::thread, std::fs, or std::net in a browser WASM module. The wasm32-unknown-unknown target strips these out. If you try to use them, the compiler rejects the code. You'll see errors like E0432 (use of undeclared crate or module) or trait bound failures. Use web-sys or js-sys for browser APIs instead. These crates provide safe Rust bindings to the DOM and web APIs.
Printing is another issue. println! is a no-op in WASM. The output goes nowhere. Use web_sys::console::log_1 for logging, or rely on console_error_panic_hook for panics.
Memory management requires care. wasm-bindgen handles memory for JsValue and String, but if you hold references across async boundaries incorrectly, you can leak memory. The WASM heap doesn't have a garbage collector that knows about Rust allocations. If you allocate memory in Rust and forget to free it, the heap grows until the browser kills the tab.
Error handling across the boundary needs attention. Rust Result types convert to JavaScript exceptions. If you return Result<T, E>, wasm-bindgen throws an error in JS if the result is Err. This is usually what you want. If you need custom error handling, implement the wasm_bindgen::describe::WasmDescribe trait or use JsValue to pass errors explicitly.
Don't assume std works. The browser is a sandbox. Respect the boundaries.
Decision matrix
Use wasm-pack when you are building a Rust library that needs to be consumed by JavaScript projects. It generates the npm package structure and bindings automatically.
Use wasm-bindgen directly when you need fine-grained control over the binding generation process, such as customizing the output directory or integrating into a custom build script.
Use trunk when you are building a full-stack web application with Rust and want a single tool to handle HTML, CSS, WASM, and hot reloading.
Use cargo build --target wasm32-unknown-unknown when you only need the raw .wasm binary for a custom toolchain or a non-JavaScript host environment.
Use wasm-pack test when you need to run your Rust unit tests inside a real browser environment to catch browser-specific issues.
Pick the tool that matches your output. Library? wasm-pack. App? trunk. Raw binary? cargo.