Running Rust in the Browser
WebAssembly has transformed how we think about running native code in the browser, and Rust is one of the best languages to target it. If you've felt the limitations of JavaScript performance or wanted to reuse existing Rust libraries on the client side, this guide will show you how to build, optimize, and deploy Rust code that runs directly in web browsers.
Over the next few sections, we'll walk through the complete workflow: compiling Rust to WebAssembly, binding JavaScript and Rust together, exploring real-world use cases, and squeezing every kilobyte out of your bundle.
wasm-pack — Build Rust for JavaScript Projects
wasm-pack is the standard tool for packaging Rust code as a JavaScript module. It handles the entire compilation pipeline: it invokes the Rust compiler, applies necessary transformations, and generates both the WebAssembly binary and JavaScript bindings. This saves you from wrestling with low-level toolchain configuration.
To get started, install wasm-pack if you haven't already:
cargo install wasm-packNext, create a new Rust library project configured for WebAssembly:
cargo new --lib image_processor
cd image_processorUpdate your Cargo.toml to declare it as a WebAssembly package:
[package]
name = "image_processor"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"
image = "0.24"The crate-type = ["cdylib"] line tells Rust to compile this as a dynamic library suitable for WebAssembly. Now write a simple function in src/lib.rs:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn grayscale_image(width: u32, height: u32, data: Vec) -> Vec {
let mut result = vec![0; data.len()];
for i in (0..data.len()).step_by(4) {
let r = data[i] as f32;
let g = data[i + 1] as f32;
let b = data[i + 2] as f32;
let a = data[i + 3];
let gray = (0.299 * r + 0.587 * g + 0.114 * b) as u8;
result[i] = gray;
result[i + 1] = gray;
result[i + 2] = gray;
result[i + 3] = a;
}
result
}Build it with:
wasm-pack build --target bundlerThe --target bundler flag produces an npm-ready package. You'll see a pkg/ directory with your compiled WebAssembly module and generated JavaScript bindings. The module is now ready to install as a local dependency in your JavaScript project.
In your project's package.json:
{
"dependencies": {
"image_processor": "file:../image_processor/pkg"
}
}Then import and use it:
import * as wasm from 'image_processor';
const imageData = getImagePixelsAsUint8Array();
const processed = wasm.grayscale_image(width, height, imageData);
applyToCanvas(processed);wasm-bindgen — Call Between Rust and JavaScript
The #[wasm_bindgen] macro is what makes Rust and JavaScript talk to each other. It automatically generates boilerplate that marshals data across the boundary, handling type conversions and memory management.
Let's look at a more complex scenario involving structs. Suppose we're building a parser:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct JsonParser {
data: String,
}
#[wasm_bindgen]
impl JsonParser {
#[wasm_bindgen(constructor)]
pub fn new(input: String) -> JsonParser {
JsonParser { data: input }
}
pub fn parse(&self) -> Result {
serde_json::from_str::(&self.data)
.map(|v| serde_wasm_bindgen::to_value(&v).unwrap())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}From JavaScript:
import * as wasm from 'my_parser';
const parser = new wasm.JsonParser('{"key": "value"}');
const result = parser.parse();
console.log(result);The #[wasm_bindgen(constructor)] attribute tells the macro to expose this as a JavaScript constructor. Instance methods become prototype methods. The macro handles the tedious work of converting between Rust types and JavaScript values.
For callbacks and async operations, you can work with JavaScript promises directly:
use wasm_bindgen_futures::JsFuture;
use web_sys::window;
#[wasm_bindgen]
pub async fn fetch_and_process(url: String) -> Result {
let window = window().ok_or_else(|| JsValue::from_str("No window"))?;
let resp = JsFuture::from(window.fetch_with_str(&url))
.await?;
let text = JsFuture::from(resp.text()?)
.await?
.as_string()
.ok_or_else(|| JsValue::from_str("Not a string"))?;
Ok(process_text(&text))
}This function is awaitable from JavaScript as a Promise, making integration seamless.
Use Cases — Image Processing, Parsers and Games
Image processing is a classic WebAssembly win. Browser canvas operations are fast, but pixel-level transformations benefit from compiled code. A grayscale conversion, blur filter, or histogram calculation written in Rust will outpace JavaScript by orders of magnitude.
We showed grayscale above. Here's a fast blur filter:
#[wasm_bindgen]
pub fn blur_image(width: u32, height: u32, data: Vec, radius: u32) -> Vec {
let mut result = vec![0; data.len()];
let r = radius as usize;
for y in 0..height {
for x in 0..width {
let mut r_sum = 0u32;
let mut g_sum = 0u32;
let mut b_sum = 0u32;
let mut count = 0u32;
for dy in (y.saturating_sub(r))..=(y + r).min(height - 1) {
for dx in (x.saturating_sub(r))..=(x + r).min(width - 1) {
let idx = ((dy * width + dx) * 4) as usize;
r_sum += data[idx] as u32;
g_sum += data[idx + 1] as u32;
b_sum += data[idx + 2] as u32;
count += 1;
}
}
let out_idx = ((y * width + x) * 4) as usize;
result[out_idx] = (r_sum / count) as u8;
result[out_idx + 1] = (g_sum / count) as u8;
result[out_idx + 2] = (b_sum / count) as u8;
result[out_idx + 3] = data[out_idx + 3];
}
}
result
}Parsers are another strong fit. CSV parsing, protocol buffers, MessagePack, regex matching on large datasets—these all benefit from Rust's speed and safety. You can pull in existing Rust crates like csv, serde, or regex and expose a clean JavaScript API.
Games and interactive graphics push WebAssembly further. Libraries like bevy, ggez, or custom rendering engines can run in the browser. Physics calculations, AI logic, and game loop updates are noticeably snappier than pure JavaScript.
Size — Optimize with wasm-opt and Release Builds
WebAssembly binaries are compact by nature, but they still need optimization for real-world deployment. A naive debug build might be 1–2 MB; a release build typically shrinks to 200–500 KB; with proper optimization, you can reach 50–150 KB for many real applications.
First, always build in release mode:
wasm-pack build --target bundler --releaseThis enables LLVM optimizations and removes debug symbols. The size difference is dramatic.
Next, install wasm-opt from the Binaryen toolkit:
npm install -g wasm-optThen run it on your compiled binary:
wasm-opt -Oz -o pkg/image_processor_bg_optimized.wasm pkg/image_processor_bg.wasmThe -Oz flag aggressively optimizes for size. -O3 optimizes for speed instead. Measure your use case—sometimes speed wins outweigh a few extra kilobytes.
Additional tricks:
- Use
wasm-stripto remove name sections:wasm-strip pkg/image_processor_bg.wasm - Enable LTO (Link Time Optimization) in
Cargo.toml:[profile.release] lto = true opt-level = "z" - Remove unused code with a bundler tree-shake pass (webpack, rollup, or esbuild all support this)
- Consider shipping multiple variants: a small, fast path for common cases and a full-featured version for advanced users
A real example: an image processing library we built went from 450 KB (debug) to 120 KB (release) to 65 KB (release + wasm-opt + LTO). At that size, it's practical to ship alongside any modern web application.
Common Mistakes
Forgetting the #[wasm_bindgen] attribute. Without it, your functions won't be exposed to JavaScript at all. Every public function you want callable from JS needs this macro.
Passing large vectors repeatedly. Each call across the wasm boundary involves a memory copy. For image processing on large images, consider accepting raw pointers and managing memory more explicitly, or use wasm-bindgen-futures to offload work to workers.
Ignoring bundle size during development. It's easy to add dependencies without realizing they bloat your binary. Audit your dependencies with cargo tree and use twiggy to identify size hotspots in your compiled output.
Assuming automatic garbage collection. WebAssembly memory is linear and manual. If you allocate Rust structures and pass them to JavaScript, understand when they're dropped. Use wasm-bindgen's lifecycle annotations carefully.
Not profiling in the browser. WebAssembly is fast, but not infinitely so. Use Chrome DevTools to profile your wasm code alongside your JavaScript. Sometimes the bottleneck is data transfer, not computation.
Key Takeaways
- wasm-pack is your entry point; it orchestrates compilation, bindings, and packaging into an npm module.
- wasm-bindgen macros bridge Rust and JavaScript seamlessly, handling type marshalling and lifecycle management.
- Image processing, parsers, and game logic are proven use cases where WebAssembly delivers real performance gains.
- Optimize aggressively with release builds, wasm-opt, and LTO to keep bundle size practical.
- Always profile in real browsers; the performance win over JavaScript is substantial but not magic.
- Start small and build incrementally; migrating entire applications to wasm is rare—targeting hot paths is the norm.






Comments
Loading comments…