Rust WASM Breakthrough: Ship Blazing Browser Code Fast
Rust WebAssembly basics: wasm-bindgen, wasm-pack bundling, fast JS interop, sub-500KB bundles.
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓Rust ownership and Cargo basics
- ✓Basic JavaScript and browser devtools
- ✓Has shipped one small web page
- Rust compiles to WASM for near-native browser speed via wasm-bindgen plus wasm-pack
- Core pieces: cdylib crate type, #[wasm_bindgen] exports, pkg/ bundle, JS module import
- Performance insight: batched buffer calls process 2M pixels in 41ms, a 9x win over JS at 380ms
- Production insight: per-pixel calls flipped that win into a 3fps editor until batching landed
- Biggest trap: dropped Closure handles kill event listeners after two fires with zero errors
Think of a food truck with a race engine. JavaScript is the friendly vendor taking orders and chatting with customers. Rust compiled to WASM is the race engine in the back that grinds 2,000 orders of spices per minute. The vendor passes big bags in and gets finished blends back, instead of handing over one seed at a time.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
WebAssembly lets Rust run in browsers at near-native speed. You'll compile a hot function, load it from JavaScript, and watch a filter run 9x faster. No plugins needed.
The boundary is where teams stumble. You'll pass whole buffers instead of per-pixel calls and keep closures alive correctly. Small habits decide everything.
Don't rewrite your app though. You'll profile first, port the 5% that burns CPU, and keep UI in JS. WASM stays surgical.
Setup is three steps: add the wasm32-unknown-unknown target, mark the crate cdylib, and build with wasm-pack. rustup target add wasm32-unknown-unknown installs codegen, crate-type = ["cdylib"] emits a loadable module, and wasm-pack build --target web writes pkg/ with glue.
Serve over HTTP during development. Browsers block WASM loads from file:// URLs, so python3 -m http.server in the project root unblocks testing in seconds.
#[wasm_bindgen] exports functions to JavaScript. Plain numbers and strings cross freely; complex structs need explicit conversion or serialization. Keep signatures flat: numbers in, numbers or strings out.
Annotate sparingly and test each export from Node before wiring the UI. wasm-pack test --node runs exports headlessly, catching signature mistakes 10x faster than browser refresh loops.
Batching decides whether WASM wins. One call carrying a 2M-pixel buffer runs in 41ms; 2M calls carrying one pixel each take 380ms. The math is identical; only the call count changes.
Design exports around buffers: accept &[u8], process in place, return counts or compact results. Measure with performance.now() around the batch so regressions show as numbers, not vibes.
Callbacks need Closure::wrap to become JS functions. A stack-local Closure drops at function end, leaving the browser with a dangling hook that fires twice then stops. No error appears in any console.
Persistent listeners must their handle or store it globally. One-line omission, week-long debug. Test navigation and re-registration 10 times before shipping.forget()
Memory is one linear buffer shared with JS. Allocate inside exported functions, return owned values, and never cache &str views across calls. Reallocation or GC can invalidate views silently.
Watch total size: keep first-load modules under 500KB compressed. Split editors and codecs into lazy chunks loaded after first paint.
Test in two layers: pure logic with cargo test, browser behavior with wasm-pack test. Logic tests iterate in milliseconds natively; only boundary and DOM tests need a browser. Most bugs live in logic, so keep them native.
Profile before porting more code. Devtools flame graphs show whether Rust or glue dominates. Port the next hotspot only when measurements justify it.
The 2M Boundary Calls That Dropped an Editor to 3fps
performance.now() timing assert failing CI on regressions over 50ms. Frame time fell from 380ms to 41ms in one deploy. They also lazy-loaded the 1.2MB module after first paint, cutting load-blocking time to 90ms.- Boundary calls cost microseconds each: batch whole buffers or lose the 9x advantage.
- Assert frame-time budgets in CI so the next per-pixel regression fails a build, not users.
- Lazy-load large modules after first paint; 1.2MB of WASM must never block it.
rustup target add wasm32-unknown-unknown then wasm-pack build --target web. Verify pkg/ contains .wasm plus .js glue, and serve over python3 -m http.server since modules refuse file URLs.wasm-pack test --node to reproduce outside the browser, then batch calls: pass the full Uint8Array once instead of per-pixel. Measure with performance.now() around the batch to confirm the 9x win.Closure::wrap, call .forget() for persistent listeners, and store the handle in a global so Rust never drops it.cargo build --target wasm32-unknown-unknown for logic errors plus wasm-pack test --headless --chrome for browser behavior. Keep pure logic in cargo test where iteration runs 10x faster.Key takeaways
Common mistakes to avoid
4 patternsBuilding WASM without the cdylib crate type
cargo build --target wasm32 emits an rlib nobody can load, and the browser throws expected a WebAssembly.Module on import.crate-type = ["cdylib"] in Cargo.toml and build with wasm-pack build --target web. Run wasm-pack from the crate root so pkg/ lands beside the crate.Passing complex Rust types across the JS boundary
null pointer errors, and debugging takes hours in generated glue code.&str and String at the boundary through wasm_bindgen conversions, and pass numbers or strings only. Run wasm-pack test --node to prove the boundary contract.Dropping JS closures too early
Closure and the browser kept a dangling reference.Closure::wrap, call forget() for listeners that must live, and store the handle. Test page navigation 10 times watching for callback death.Holding borrowed views across JS calls
&str view pointed at reallocated memory.Interview Questions on This Topic
How does Rust code reach the browser as WASM?
wasm32-unknown-unknown, exposing functions through #[wasm_bindgen] attributes. wasm-pack bundles the .wasm plus JS glue into a pkg/ directory the browser imports as a module. Memory is a shared linear buffer; strings and arrays cross via generated conversions.Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's WASM. Mark it forged?
3 min read · try the examples if you haven't