| name | rust-wasm |
| description | | Use when this capability is needed. |
Rust WebAssembly
Practical guide for shipping Rust code as WebAssembly: small boundaries, typed interop, predictable memory, and measurable bundle size.
Quick Navigation
- references/bindings.md - wasm-bindgen, web-sys, JS types, async interop
- references/bundling.md - wasm-pack, Trunk, Vite, size optimization, deployment
Golden Rules
- Keep the Rust/JS boundary coarse-grained.
- Pass bytes and IDs instead of many tiny objects.
- Enable panic hooks in debug builds, not production hot paths.
- Measure
.wasm size after wasm-opt, not before.
- Treat browser APIs as fallible external systems.
- Use
cfg(target_family = "wasm") for platform-specific code.
- Avoid
wasm-bindgen on hot loops — pass whole buffers.
Project Setup
rustup target add wasm32-unknown-unknown
cargo install wasm-pack
cargo new image-core --lib
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
wasm-bindgen = "0.2"
js-sys = "0.3"
[dependencies.web-sys]
version = "0.3"
features = ["console", "Window", "Document", "HtmlCanvasElement"]
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
Export a Small API
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Filter {
strength: f32,
}
#[wasm_bindgen]
impl Filter {
#[wasm_bindgen(constructor)]
pub fn new(strength: f32) -> Self {
Self { strength }
}
pub fn apply_rgba(&self, pixels: &mut [u8]) {
for chunk in pixels.chunks_exact_mut(4) {
let gray = (chunk[0] as f32 * 0.299
+ chunk[1] as f32 * 0.587
+ chunk[2] as f32 * 0.114) as u8;
for channel in &mut chunk[..3] {
*channel = ((*channel as f32 * (1.0 - self.strength))
+ (gray * .strength)) ;
}
}
}
}
Use &mut [u8] for image/audio/data buffers. Avoid one exported call per pixel or row.
Returning Complex Types
use serde::{Serialize, Deserialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
pub struct AnalysisResult {
pub mean: f64,
pub median: f64,
pub std_dev: f64,
pub count: usize,
}
#[wasm_bindgen]
pub fn analyze(data: &[f64]) -> JsValue {
let mean = data.iter().sum::<f64>() / data.len() as f64;
let result = AnalysisResult { mean, median: 0.0, std_dev: 0.0, count: data.len() };
serde_wasm_bindgen::to_value(&result).unwrap()
}
Use serde_wasm_bindgen for complex return types, but avoid on hot paths where the serialization overhead matters.
Accepting JS Callbacks
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn process_with_progress(
data: &[u8],
on_progress: &js_sys::Function,
) -> Result<(), JsValue> {
let total = data.len();
for (i, chunk) in data.chunks(1024).enumerate() {
let pct = (i as f64 / total as f64) * 100.0;
let this = JsValue::NULL;
let args = js_sys::Array::of1(&JsValue::from_f64(pct));
on_progress.call(&this, &args)?;
}
Ok(())
}
For hot paths, prefer passing a channel or buffer over function callbacks to avoid per-call overhead.
Wasm Threads (Shared Memory)
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
#[wasm_bindgen]
pub fn init_pool(num_workers: usize) -> Result<(), JsValue> {
console_error_panic_hook::set_once();
Ok(())
}
Threads require SharedArrayBuffer support, COOP/COEP headers, and wasm-opt compatibility flags.
Debug Hooks
#[cfg(debug_assertions)]
#[wasm_bindgen(start)]
pub fn init_debug() {
console_error_panic_hook::set_once();
}
[target.'cfg(debug_assertions)'.dependencies]
console_error_panic_hook = "0.1"
JS Interop Shape
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(message: &str);
}
#[wasm_bindgen]
pub fn greet(name: &str) {
log(&format!("hello, {name}"));
}
Prefer explicit bindings over passing untyped JsValue everywhere.
Async Browser APIs
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
#[wasm_bindgen]
pub async fn fetch_text(url: String) -> Result<String, JsValue> {
let window = web_sys::window().ok_or_else(|| JsValue::from_str("missing window"))?;
let response = JsFuture::from(window.fetch_with_str(&url)).await?;
let response: web_sys::Response = response.dyn_into()?;
let text = JsFuture::from(response.text()?).await?;
text.as_string().ok_or_else(|| JsValue::from_str("response text was not a string"))
}
Memory Management
Rust WASM memory (WASM linear memory) is separate from the JS heap:
wasm_bindgen copies strings and arrays by default.
- Use
&[u8] / Vec<u8> for zero-copy buffer sharing where possible.
- Large structures are more efficient as serialized bytes than as many small JS objects.
- Calling
free() on JS objects (they're JsValue with Drop) is automatic when the Rust wrapper goes out of scope.
Size Optimization
wasm-pack build --release --target web
wasm-opt -Oz -o pkg/optimized.wasm pkg/my_crate_bg.wasm
ls -lh pkg/optimized.wasm
| Technique | Typical Savings |
|---|
opt-level = "z" | 15-25% |
| LTO + 1 codegen unit | 10-20% |
wasm-opt -Oz | 20-40% |
| Remove unused features | Variable |
wee_alloc (legacy) | Smaller binary, slower alloc |
Modern Rust uses the default allocator; wee_alloc is rarely needed with LTO.
WASM in Web Workers
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn worker_entry() -> Result<(), JsValue> {
Ok(())
}
Workers cannot access the DOM (web_sys::window() returns None). Use postMessage for communication.
WASI (Wasm on the Server)
rustup target add wasm32-wasip1
cargo build --target wasm32-wasip1 --release
wasmtime run target/wasm32-wasip1/release/my-app.wasm
WASI gives you file I/O, clocks, and stdio — closer to a native binary than browser WASM.
Testing WASM
use wasm_bindgen_test::*;
#[wasm_bindgen_test]
fn test_filter_applies() {
let filter = Filter::new(0.5);
let mut pixels = vec![100u8, 150, 200, 255, 50, 75, 100, 255];
filter.apply_rgba(&mut pixels);
assert!(pixels[0] < 100);
}
wasm-pack test --firefox
wasm-pack test --headless
Anti-Patterns
#[wasm_bindgen]
pub fn set_pixel(x: u32, y: u32, r: u8, g: u8, b: u8) { }
#[wasm_bindgen]
pub fn apply_image_filter(rgba: &mut [u8]) { }
let document = web_sys::window().unwrap().document().unwrap();
let document = web_sys::window()
.and_then(|window| window.document())
.ok_or_else(|| JsValue::from_str("document unavailable"))?;
let js_val = serde_wasm_bindgen::to_value(&my_data).unwrap();
static CACHED: OnceLock<JsValue> = OnceLock::new();
Production Checklist
- Build release artifacts with
wasm-pack build --release or equivalent.
- Run
wasm-opt -Oz for size-sensitive web apps.
- Keep exported functions stable and documented like a public API.
- Avoid
serde_wasm_bindgen on hot paths unless ergonomics beat copy cost.
- Test core logic natively with normal Rust tests; test bindings with
wasm-bindgen-test.
- Verify target environment: browser main thread, worker, Node, edge runtime, or WASI.
- Set
panic = "abort" in release profile.
- Measure bundle size in CI and set a budget.
References
Source: adxptived/Rust-Skills — distributed by TomeVault.