Rust WASM Basics: Ship Small, Fast Browser Modules
Compile Rust to WebAssembly with wasm32-unknown-unknown, bind exports with wasm-bindgen, bundle with wasm-pack --target web.
20+ years shipping production backend systems. Written from production experience, not tutorials.
- ✓Rust ownership, borrowing, and Cargo basics
- ✓Basic JavaScript plus browser DevTools
- ✓Has shipped one small web page
- Rust compiles to WebAssembly through the wasm32-unknown-unknown target, which emits sandboxed bytecode the browser streaming-compiles in milliseconds with zero installs
- wasm-bindgen exports Rust functions to JavaScript and imports browser APIs into Rust, converting numbers, strings, and byte slices across one shared linear memory buffer
- wasm-pack build --target web bundles the compiled .wasm plus generated JS glue into pkg/, ready for native ES-module import, with bundler, nodejs, and no-modules targets for other hosts
- Speed comes from batching: one call carrying a 2M-pixel buffer finishes in about 41ms, while 2M per-pixel calls take around 380ms and drop the same filter to 3fps
- Shrink first-load modules under 500KB compressed with release opt-level=z, lto=true, wasm-opt -Oz, and lazy-load the rest; debug panics with console_error_panic_hook so stack traces reach the console
- Browsers run WASM against the DOM through JS glue, while WASI plus Wasmtime runs the same Rust outside the browser with files and clocks; pick the host before you pick the API surface
Picture a restaurant kitchen where the wait staff take orders, chat with guests, and carry plates, while a separate high-speed prep machine in the back dices two thousand onions a minute without ever talking to a customer directly. JavaScript is the wait staff: friendly, flexible, in charge of everything you see and touch on the page. Rust compiled to WebAssembly is the prep machine: it never seats a guest or pours water, but when you slide a full tray of raw pixels, audio samples, or text through its slot, it hands back finished work at near-native speed. The trick the whole guide teaches is passing full trays back and forth instead of single onion slices, keeping the machine small enough to wheel in fast, and knowing exactly which jobs deserve the machine and which should stay with the staff.
You've got a web app that feels instant until someone drops a 12-megapixel photo into it. The JavaScript filter locks the tab for half a second, the fan spins up, and the user starts wondering if your editor is broken. You've profiled it, you've memoized what you can, and the hot loop is still just math your CPU should chew through in milliseconds.
That's the exact gap WebAssembly was built to close. It lets you compile Rust — with its lack of a garbage collector, its tight monomorphized loops, and its predictable memory layout — into portable bytecode the browser executes at near-native speed. No plugins, no installs, no separate download. The user loads a page and your Rust function is callable from JavaScript like any other module.
But teams stumble at the boundary, not the language. They export one function per pixel and wonder why 2 million boundary crossings erase a 9x math win. They drop a Closure handle and spend a week chasing a listener that fires twice then goes silent. They ship a 1.8MB module on first paint and tank their Lighthouse score while blaming the compiler.
You'll learn the full loop here: installing the wasm32-unknown-unknown target, marking the crate cdylib, exporting flat functions with wasm-bindgen, bundling with wasm-pack build --target web, batching buffers instead of values, shrinking the artifact with opt-level and wasm-opt, splitting browser builds from WASI builds run under Wasmtime, and wiring console_error_panic_hook so a Rust panic shows up in DevTools with a usable trace. You've shipped one web page and you know basic Rust ownership — that's enough to follow along.
Don't rewrite your app in Rust. Profile first, port the 5% that burns CPU, and keep every button, router, and DOM update in JavaScript where it belongs. WASM stays surgical, and this guide shows you where to cut.
What WebAssembly Is and Why Rust Fits It Better Than Most Languages
WebAssembly is a portable bytecode that browsers execute in a sandboxed linear memory at near-native speed, and it helps to picture exactly what that sentence buys you. When JavaScript runs your hot loop, the JIT compiler guesses at types, speculates on shapes, and occasionally deoptimizes mid-frame when a hidden class changes. When WASM runs the same loop compiled from Rust, the browser streaming-compiles validated bytecode into machine code before the download even finishes, then executes tight instructions with no garbage-collector pauses and no speculation cliffs. The sandbox is strict: same-origin policy applies, there are no raw sockets, memory is one isolated linear buffer per instance, and every interaction with the page flows through explicit imports and exports you can audit.
Rust fits this model better than most languages for three concrete reasons. First, it has no garbage collector, so the compiled module carries almost no runtime — the canonical add-two-numbers example produces about 710 bytes of WASM before optimization and roughly 172 bytes after wasm-opt -Os, which is smaller than most framework logos. Second, ownership and borrowing give predictable memory layout: a Vec<u8> is a pointer plus length plus capacity you can reason about, monomorphized generics collapse to direct calls, and release-mode LLVM output looks like hand-tuned C. Third, the toolchain treats WASM as a first-class target rather than an afterthought, with rustup distributing prebuilt std for wasm32-unknown-unknown and the broader Rust and WebAssembly working group maintaining the bindgen, pack, and debugging story.
The honest limits matter as much as the wins. WASM cannot touch the DOM directly, so every canvas pixel, fetch call, or event listener crosses through JavaScript glue that costs microseconds per call. It starts fast but downloads like any other asset, which means a 1.8MB module on a cable connection blocks interactivity longer than any math speedup recovers. And the sandbox cuts both ways: a malicious page can pass any pointer it likes, so your Rust side must validate every buffer length instead of trusting caller-supplied offsets. Teams that respect these three constraints — batch the boundary, budget the bytes, validate the inputs — routinely report 5-10x wins on codecs, filters, parsers, and crypto. Teams that ignore them port whole apps and wonder why a faster language produced a slower page.
The compilation model underneath deserves a plain-English tour because it explains every later tool choice. Your Rust source lowers through MIR and LLVM to a single WASM module with its own memory, table, and globals, plus an import list naming the host functions it expects. The wasm-bindgen CLI then rewrites that module: it strips dead exports, generates marshaling shims for strings and slices, and emits the JavaScript file your page actually imports. Nothing about this pipeline requires a bundler, which is why the --target web output runs as a native ES module with an explicit init() step that fetches and instantiates the binary. When developers complain that WASM feels like three tools doing one job, they are describing this exact split: rustc owns codegen, bindgen owns the boundary, pack owns distribution.
Two numbers keep expectations honest. A cold 12MP filter in optimized JavaScript typically lands near 45ms per frame on a mid-range laptop, which is already 30fps territory for casual use. The same filter in batched Rust WASM lands near 41ms only when buffers cross whole — yet drops toward 380ms when crossed per pixel, a spread entirely decided by call count rather than language speed. The lesson is not that Rust always wins; it is that the boundary decides who wins before the math runs. Profile the JavaScript baseline first, record input sizes honestly, and demand that any port beat the baseline by at least 2x end to end including glue costs. Porting without that baseline is how teams rewrite 40,000 lines to save 4ms nobody noticed.
Installing the wasm32-unknown-unknown Target and Scaffolding Your First Crate
The wasm32-unknown-unknown target is the minimal WebAssembly compilation target: it assumes almost nothing about the host, imports no platform functions for std, and leaves the question of how to talk to the outside world to tools like wasm-bindgen. That minimalism is why the web uses it — browsers expose no standard set of syscalls for WASM to import, so the module must declare its own imports that JavaScript satisfies at instantiation. Installing it takes one command, and verifying it takes two more, which makes this the cheapest step in the whole pipeline and the one newcomers most often skip validating before blaming later stages.
Start from a working stable toolchain and run rustup target add wasm32-unknown-unknown, then confirm with rustup target list --installed piped through a grep for the triple. Scaffold with cargo new --lib wasm-filter and open Cargo.toml: under [lib] set crate-type to cdylib so rustc emits a loadable dynamic library instead of an rlib archive no browser can instantiate. Add wasm-bindgen to dependencies at a pinned version, because the wasm-bindgen CLI version that wasm-pack downloads must match the library version in your lockfile or the build fails with an exact-version error message that confuses first-timers. Keep the crate small at first — one file, one export — so every later failure points at the new thing you just added.
Prove the plumbing before adding logic. Run cargo build --target wasm32-unknown-unknown and confirm target/wasm32-unknown-unknown/debug appears; then run wasm-pack build --target web from the crate root and confirm pkg/ contains a _bg.wasm binary plus a .js glue file plus TypeScript definitions. Serve the directory over HTTP with python3 -m http.server 8080 and import the module from a script tag, because ES-module WASM instantiation refuses file:// URLs in every modern browser. If the browser throws expected a WebAssembly.Module at this stage, the cause is almost always a missing cdylib line or running wasm-pack from the wrong directory so pkg/ landed beside the wrong crate. Script these three checks into your README so the next frontend developer onboards in under an hour instead of filing a toolchain ticket.
A concrete first crate makes the abstract steps stick, so walk this exact sequence on your machine tonight. After cargo new --lib, set edition to 2021, add wasm-bindgen 0.2.92 pinned, and write a single add export plus a greeting export that formats a &str into a String — two functions that exercise numeric and string crossings with zero DOM involvement. Run cargo check to catch type errors natively in milliseconds, then cargo build --target wasm32-unknown-unknown to prove codegen, then wasm-pack build --target web to produce pkg/ with the binary, glue, and definitions. Open pkg/app.js and skim it: you will see malloc wrappers, string encoders, and the init() function that wires fetch to instantiation. Reading thirty lines of that glue teaches more about ownership across the boundary than any diagram.
Workspace layout trips up real teams more than syntax does. If your crate lives inside a Cargo workspace, wasm-pack must still run from the crate directory so relative paths resolve, and the [lib] section must sit in the crate's own Cargo.toml rather than the workspace root. Commit a three-line script running the target check, the pack build, and an ls of pkg/ so contributors never guess. Developers who script onboarding finish their first WASM export the same afternoon. Developers who document it as folklore answer the same Slack question every month.
Package managers add one more wrinkle worth defusing early. The wasm-bindgen CLI version is resolved from Cargo.lock at build time, so deleting the lockfile to fix an unrelated failure silently upgrades the bridge and breaks the version match. Commit the lockfile for applications, review its bindgen bump like any dependency change, and re-run the Node suite after upgrades. A green lockfile bump is a five-minute chore. A surprise bridge mismatch on release day is a midnight rollback.
wasm-bindgen: Exporting Rust Functions and Importing Browser APIs
wasm-bindgen is the bridge that turns Rust items into JavaScript-callable exports and JavaScript APIs into Rust-callable imports, and its design rewards flat signatures. Annotating a function with #[wasm_bindgen] instructs the macro to generate conversion shims: a u32 crosses as a number, a &str borrows from linear memory with explicit length, a String allocates and returns an owned pointer the glue later frees. Numbers and short strings cross freely. Structs, enums with data, and trait objects do not cross without deliberate serialization or wrapper types, and pretending otherwise produces opaque pointer errors that send developers spelunking through generated glue they were never meant to read.
The export direction covers math, parsing, and transformation: pub fn add(a: u32, b: u32) -> u32 becomes an importable JS function after wasm-pack runs the matching wasm-bindgen CLI. The import direction covers the environment: extern blocks declaring window.alert, console.log, or canvas methods let Rust request host behavior without linking platform code into the module. Keep both directions narrow. An export surface of five flat functions with numeric and string types is easy to test, easy to version, and easy to keep fast. An export surface of forty methods passing nested structs invites breaking changes every sprint and multiplies the glue the browser must parse.
Test each export from Node before wiring any UI. Running wasm-pack test --node executes your #[wasm_bindgen_test] cases headlessly in seconds, catching signature mistakes roughly ten times faster than a browser refresh loop with DevTools open. When a test fails with null pointer or out-of-bounds memory access, the culprit is usually a mismatched expectation about who owns the allocation: Rust allocated, JS freed twice, or a borrowed view outlived the call. Shrink the reproduction to one function and one assertion, prove it green in Node, then promote it to the headless Chrome suite with wasm-pack test --headless --chrome for DOM-adjacent behavior. The discipline of proving the boundary in Node first is what separates teams that ship WASM in a week from teams still debugging glue in month two.
Imports deserve equal attention because most real features read from the host as well as write to it. Declaring extern functions for console logging, performance timing, or fetch wrappers keeps Rust focused on transformation while JavaScript owns effects, a split that mirrors the browser security model: WASM computes, the page acts. Annotate each import with its js_namespace, keep argument lists flat, and convert fallible host calls into Result returns rather than panicking across the boundary. A fetch helper that returns a Promise maps naturally onto an async Rust wrapper with wasm-bindgen-futures, but resist pulling your entire async graph into WASM on day one — start with synchronous pure functions, prove them fast, then expand toward async only where measurements justify the added glue.
Versioning the export surface is production hygiene teams learn late. Every exported name becomes a public API your TypeScript imports, so renaming a Rust function without updating the frontend breaks the page at load with an import error no test catches unless pkg/ types are checked in CI. Generate TypeScript definitions with every build, import them in your frontend, and let tsc fail the pull request when shapes drift. One team gated merges on pkg/ type checks and caught eleven breaking renames in a quarter that would otherwise have shipped as blank screens. Small surface, pinned versions, typed imports: boring rules that keep bilingual codebases shippable.
Testing layers compound: keep pure math in native cargo test with dozens of fixtures, boundary shapes in Node suites asserting exact strings and counts, and only DOM-touching paths in headless Chrome. Most defects live in the first layer, where iteration costs milliseconds. Pushing logic tests into the browser multiplies every red-green cycle by browser startup and teaches developers to test less often, which is how boundary regressions survive to staging.
Passing Strings, Slices, and Structs Across the JS Boundary Without Corruption
Linear memory is one shared byte array, and every interesting bug in WASM interop is a disagreement about who owns which slice of it. When JavaScript passes a string, the glue encodes UTF-8, allocates space inside the module, writes bytes plus length, and hands Rust a &str view valid for exactly that call. When Rust returns a String, it allocates, leaks the pointer plus length to the glue, and trusts generated finalizers to free after JavaScript copies the value out. Numbers cross by value with no allocation at all, which is why pixel math wants &[u8] and &mut [u8] instead of per-element calls. Hold a borrowed view past the return — stash a &str in a static, cache a slice across invocations — and the next allocation or garbage-collection pause silently invalidates it, producing garbled text or garbage pixels that never reproduce in a debugger.
The safe contract is short: allocate inside the exported function, return owned values, validate every length on the Rust side. Accept &str and return String for text transforms so ownership is unambiguous. Accept &[u8] or &mut [u8] for binary work so the JavaScript side passes a Uint8Array view and reads results in place with zero copies beyond the initial write. For structs, serialize explicitly with serde_json or a compact binary schema rather than attempting to pass Rust types directly; one creative field shape from JS then fails loudly at the schema boundary instead of trapping deep inside generated conversion code. Never trust caller-supplied offsets or lengths — a hostile or buggy page can pass any pointer, and bounds checks in Rust are the last line before a trap.
Measure the boundary instead of theorizing about it. A pixel inverter processing 2M pixels in one call completes in about 41ms end to end; the same math invoked once per pixel takes roughly 380ms because 2M glue crossings at ~150ns each plus conversion dominate 8:1 over computation. Wrap both variants in performance.now() timing, run each ten times, and keep the numbers in a comment above the export so the next contributor sees the budget. Teams that batch whole buffers keep their 9x math win. Teams that cross per element ship a slower app in a faster language and blame the wrong layer.
Struct handling has one more mature pattern worth naming: schema-first JSON at the boundary for control plane data, raw buffers for data plane payloads. Configuration, job descriptors, and tool parameters cross as JSON strings validated with serde, where a malformed field fails loudly with a path-qualified error. Pixels, audio frames, and token buffers cross as &[u8] views where every nanosecond counts and schemas would only add overhead. Mixing the two — JSON for a 2M-pixel frame or raw offsets for a nested config object — pays both costs and gains neither benefit. Draw the line explicitly in code review: if a reviewer cannot tell whether an export is control or data plane from its signature, the signature is wrong.
Ownership across async gaps needs a final warning. A &str borrowed for a synchronous call is safe; the same view held across an await point while JavaScript reallocates is a use-after-free wearing a friendly type. Clone into an owned String before any await, pass indices rather than references through queues, and keep a stress test that interleaves allocation with concurrent calls. The bugs this prevents never appear in demos with one user and one image. They appear on launch day with four thousand concurrent editors, which is exactly when you cannot afford them.
Binary size audits belong in the same review as API changes. When a pull request adds a dependency, the diff should show pkg/ gzip delta alongside line counts, because a 40KB convenience import that saves ten lines costs every future visitor real milliseconds. Reviewers who ask for the byte delta get smaller bundles. Reviewers who wave it through get 900ms first paints within two quarters. Review the files array in pkg/package.json once per release so only the binary, glue, definitions, and license ship to users.
Building and Publishing with wasm-pack: From cargo build to pkg/
cargo build --target wasm32-unknown-unknown proves your Rust compiles for the target, but it does not produce a shippable web module — that job belongs to wasm-pack, which orchestrates rustc, the wasm-bindgen CLI, and wasm-opt into one pkg/ directory. Running wasm-pack build --target web from the crate root compiles in release, strips unneeded exports through the bindgen CLI, and emits three artifacts you can inspect: a _bg.wasm binary that is the real payload, a .js glue file that handles allocation plus string encoding plus instantiation, and TypeScript definitions that keep your frontend imports honest. The --target flag selects the module shape: web emits a native ES module you instantiate manually with an init() call, bundler emits Webpack-compatible output with sideEffects false, nodejs emits CommonJS for require(), no-modules mutates global state for legacy pages, and deno targets the Deno runtime. Most browser apps want web; most bundled apps accept the default.
The publish flow mirrors any npm package with one extra build step. Version the crate, run wasm-pack build --release --target web, inspect pkg/package.json for the files array, and either pack it into your app's node_modules via your bundler or publish the pkg/ directory to a registry with wasm-pack publish. Pin the wasm-bindgen dependency version in Cargo.toml and Cargo.lock, because wasm-pack downloads a CLI whose version must match the library exactly — a mismatch fails loudly and sends newcomers searching for phantom syntax errors. Keep the out directory default unless your monorepo demands otherwise, and commit neither target/ nor pkg/ to version control; both are regenerable artifacts that bloat diffs and cause merge conflicts.
Treat the build as a pipeline with budgets, not a single command you run by hand. A CI job that runs wasm-pack build --release --target web, then asserts gzip-compressed pkg/ size under 500KB, then runs wasm-pack test --node plus wasm-pack test --headless --chrome, catches the three most common regressions — version skew, size blowout, and boundary breakage — before they reach staging. Teams that wire this pipeline onboard frontend contributors who never touch Rust: they edit TypeScript, run the same build script, and trust green checks. Teams that build by hand on laptops ship whatever happened to be installed that morning and debug version drift during releases.
Publishing details separate hobby modules from maintained ones. The pkg/package.json generated by wasm-pack carries the module, main, and types fields that let bundlers and Node resolve your binary correctly, plus a sideEffects flag bundlers use for tree-shaking. Review it once per release rather than trusting defaults blindly: confirm the files array includes only the binary, glue, definitions, and license, and confirm the version matches your crate version so downstream lockfiles tell the truth. For monorepos, point the bundler at the pkg/ directory through a workspace alias instead of copying files by hand, because hand copies drift within a week and produce the classic works-on-my-machine import failure.
Choose the target flag from the deployment contract, not habit. Ship --target web when pages import the module natively with an awaited init() before first use. Accept the default bundler target when Webpack or Vite owns module resolution and you want importable JS with tree-shaking metadata. Emit --target nodejs for headless test harnesses and SSR preprocessors that require() the module in Node. Reserve no-modules for legacy pages that load scripts globally without imports. One startup shipped web output into a bundler pipeline and spent a sprint fighting double-instantiation before switching to the bundler target; the flag is a deployment decision with runtime consequences, not a cosmetic preference.
Registry hygiene matters for teams publishing pkg/ internally. Publish with explicit version bumps tied to crate versions, document the target flag used, and note supported browsers in the package readme so consumers never instantiate a web build inside Node. Internal registries full of untagged latest builds reproduce the same drift that version pinning was invented to prevent.
Shrinking the Bundle: opt-level, LTO, wasm-opt, and the wee_alloc Note
Size is a feature in WASM because every byte downloads before your fast code runs, and the default release profile leaves real savings on the table. Start with the two Cargo settings that cost nothing: under [profile.release] set opt-level to z so LLVM optimizes for size over raw speed on cold code, and set lto to true so link-time optimization strips dead functions across crate boundaries. These two lines routinely shave 15-30% off a _bg.wasm binary with no source changes, and they compose with everything below. Measure with ls on the raw file plus gzip -k for the compressed size the network actually carries, because a 1.2MB binary that compresses to 340KB tells a different story than one that compresses to 900KB of incompressible tables.
Next, run wasm-opt -Oz on the bindgen output: wasm-opt -Oz pkg/app_bg.wasm -o pkg/app_bg.wasm rewrites the binary with size-focused passes that rustc never attempts, often removing another 10-20%. The pay-only-for-what-you-use design of wasm-bindgen means unused shims are already stripped by the CLI, so if your add-two-numbers reference still measures large, file an issue rather than hacking around it — the maintainers treat unexpected bloat as a bug. Split what remains: keep the editor shell in JavaScript, lazy-load the heavy codec with dynamic import() after first paint, and confirm via Lighthouse that blocking WASM fetch time dropped from 900ms toward 90ms. A filter users reach in eight seconds must never block the shell they need in under a second.
You will encounter wee_alloc in older guides promising smaller binaries through a size-focused allocator, and you should treat it as history rather than advice. The allocator crate is unmaintained, its size edge has narrowed as the default allocator plus LTO improved, and swapping global allocators complicates panic paths plus WASI compatibility for savings most apps can recover through opt-level and wasm-opt alone. Prefer the boring wins — release profile, LTO, wasm-opt, lazy loading, and deleting dead exports — before reaching for exotic allocators. Set a CI budget: fail the build when compressed pkg/ output exceeds 500KB for first paint, and make the developer who adds the next dependency justify the bytes in the pull request.
Release hygiene extends past flags into dependency discipline. Audit Cargo.lock for duplicate wasm-bindgen versions pulled in transitively, because two copies of the conversion runtime bloat the binary and confuse the CLI version check. Run cargo tree filtered on bindgen after adding web-sys or js-sys features, and pin features narrowly — importing the entire web-sys crate with all features can add hundreds of kilobytes of shims your page never calls. Prefer granular features like console, Window, or HtmlCanvasElement over blanket enables, and re-measure pkg/ after every dependency addition. The cheapest byte is the dependency never added.
Caching completes the size story on repeat visits. Serve the .wasm binary with immutable cache headers plus content-hashed filenames from your bundler, so second loads fetch zero bytes and first loads remain the only cost that matters. Pair that with streaming instantiation: await init() starts compiling before the download finishes, overlapping network and compile on fast connections. Teams that combine lean binaries, lazy loading, immutable caching, and streaming init report second-visit filter startups under 20ms — effectively instant — while teams shipping the same math without those four habits still show spinners.
Feature flags in Cargo complete the story: gate console logging, panic hooks, and debug helpers behind a debug-tools feature enabled for --dev builds and disabled for release, so development ergonomics never leak bytes into production. A default-no-features release profile with opt-in debugging keeps the shippable artifact honest while preserving the fast inner loop developers need. Measure pkg/ gzip size after every dependency bump so new imports justify their bytes in the pull request itself.
Browser vs WASI: When to Target the DOM and When to Reach for Wasmtime
The same Rust source can ship to two very different hosts, and choosing the host before writing code prevents the most confusing class of WASM failures. Browser builds target wasm32-unknown-unknown, talk to the page exclusively through JavaScript imports, and treat the DOM, fetch, canvas, and timers as host capabilities the glue provides. There is no filesystem, no raw clock beyond performance.now(), and no socket — every privileged operation crosses the JS boundary by design. This is the right host for filters, codecs, parsers, and crypto that accelerate a page the user already loaded. If your feature needs pixels on a canvas or tokens in a text field, you are building a browser module, full stop.
WASI builds target wasm32-wasip1 (the renamed successor of wasm32-wasi), link against a system interface exposing files, clocks, random numbers, and stdio, and execute outside any browser under runtimes like Wasmtime, Wasmer, or Node's WASI support. Here your Rust uses std::fs, std::net patterns adapted to capabilities, and ordinary println! debugging, because the host provides an operating-system-like surface instead of DOM glue. This is the right host for CLI plugins, edge functions, sandboxed user code, and server-side sandboxing where you want portability plus capability-based security without containers. Run them with wasmtime run target/wasm32-wasip1/release/app.wasm and pass directories explicitly with --dir flags, since capability handles replace ambient authority.
Mixing the hosts produces traps that look like compiler bugs. A browser module importing window or document cannot instantiate under Wasmtime, which has no DOM to satisfy those imports. A WASI module calling std::fs::read_to_string cannot load on the web, where no such import exists. Inspect with wasm2wat plus grep on import to see which host a binary expects, gate host-specific code with cfg(target_arch = wasm32) plus finer feature flags, and keep shared logic in host-free functions covered by plain cargo test so both targets exercise the same core. Decide on page one of the design doc: does this feature accelerate a loaded page or sandbox a server workload. The answer picks the target triple, the test runner, and the deployment story.
Capabilities deserve a closer look because they replace the ambient authority server developers take for granted. Under WASI, a module cannot open /etc/passwd simply because the path exists; it can only access directories the launcher explicitly grants with --dir flags, and clocks plus random sources arrive as granted capabilities rather than global syscalls. This default-deny posture is why edge platforms favor WASM for untrusted plugins: a tenant's filter cannot exfiltrate a neighbor's files when the runtime never granted the handle. Design your server modules around this model by threading explicit handles — opened directories, seeded RNGs, bounded clocks — instead of reaching for ambient globals that work locally and trap in production.
Testing across hosts stays cheap when shared logic stays host-free. Keep pixel math, parsing, and validation in pure functions over slices and strings with no cfg gates, cover them with plain cargo test running natively in milliseconds, and let each host target add only a thin adapter: JS glue on the web, capability wiring under Wasmtime. A single test suite then guards both deployments, and host-specific failures shrink to adapter bugs you can reproduce with one command. The alternative — duplicated logic per host with separate suites — doubles maintenance for zero measured benefit and diverges within two quarters.
Edge deployment adds a packaging note: bundle the WASI binary with its capability manifest and runtime version pin so staging and production grant identical directories and clocks. Capability drift between environments produces works-in-staging traps that look like code bugs but are really launcher flags. Version the manifest beside the binary and diff both in review. Pin the Wasmtime version beside the binary so staging and production grant identical capabilities every deploy.
Debugging Panics in the Browser with console_error_panic_hook and Source Maps
A Rust panic in WASM presents by default as RuntimeError: unreachable executed, which tells you nothing about which line, which invariant, or which input caused it. The cause is structural: release profiles commonly set panic to abort for size, so the module traps immediately instead of unwinding a message through the boundary. During development that tradeoff is backwards — you want maximum information per failure, not minimum bytes. The fix has two parts that take ten minutes and pay back every subsequent debugging session: install a panic hook that forwards Rust messages to the browser console, and build a debuggable artifact while iterating.
Add console_error_panic_hook to dependencies and call console_error_panic_hook::set_once() inside your initialization export or a #[wasm_bindgen(start)] function that runs at instantiation. Rebuild with wasm-pack build --target web and reload with DevTools open: the next panic logs the full Rust message, source location, and stack trace to the console instead of a bare trap. Keep the hook behind a development gate or a log-level check for production, where user-facing code should catch the failure and render a friendly fallback rather than dumping internals. Pair this with --dev builds during iteration (fast compile, readable names, larger output) and --release builds for staging validation, so you never debug optimized output when unoptimized output is one flag away.
When the trace still confuses, narrow systematically. Reproduce the panic under wasm-pack test --node with a minimal input to separate logic bugs from browser-state bugs; most panics reproduce natively because they live in pure Rust indexing or unwrap() calls. Add defensive Result returns at the boundary so recoverable errors cross as JsValue instead of traps, reserving panics for genuine invariant violations. Log inputs at the JS call site before crossing, because the trace shows where Rust gave up while the logged arguments show what you asked it to do. Teams that wire the hook on day one treat WASM panics like ordinary test failures. Teams that skip it treat every panic like a haunted browser and lose days to unreachable executed.
Source maps and symbol hygiene turn traces from clues into answers. During iteration, build without stripping so function names survive into the console trace; the extra kilobytes cost nothing on localhost and save hours of mapping minified frames. Before release, decide explicitly whether staging keeps symbols for diagnosis or strips for size, and make production rollback artifacts retain the matching symbols so incident traces stay mappable. Log the pkg/ content hash with every deploy so a trace from version N never gets mapped against sources from version N+1, a mismatch that has sent more than one team hunting a bug that no longer existed.
Error taxonomy at the boundary prevents panic overuse. Return JsValue errors for expected failures — bad input, division by zero, unsupported encoding — so JavaScript handles them with ordinary try/catch and users see actionable messages. Reserve panics for violated invariants that indicate a bug: index math the type system could not express, unreachable states after exhaustive matching. This split keeps dashboards meaningful: rising JsValue errors signal user-input drift worth addressing with validation, while any panic signals a code defect worth paging over. Teams that panic on bad input train themselves to ignore the loudest signal they have.
Console filtering keeps signal high once the hook is live. Prefix WASM logs with a module tag, mirror them into a ring buffer your error reporter flushes on failure, and sample verbose traces in production so diagnosis never depends on reproducing with DevTools open. The hook delivers the message; your logging pipeline decides whether anyone reads it at 3 AM. Tag WASM console lines with a module prefix plus a ring buffer your reporter flushes on failure, keeping traces searchable at 3 AM without DevTools open.
unwrap() on a locale string Safari formats differently. One expect() with a clear message plus a fallback path closed a bug that had survived four guess-driven fixes.set_once(), iterate on --dev builds, reproduce under Node, and return Result at the boundary so panics stay exceptional.Closures, Memory, and Lifetimes: Keeping Callbacks Alive Across Frames
Passing a Rust closure to JavaScript is a lifetime negotiation, and the compiler cannot save you from getting it wrong because the other party lives in a garbage-collected heap. The primitive is Closure::wrap, which boxes a FnMut, exposes it as a JS function reference, and ties its validity to the Rust handle's lifetime. A stack-local Closure dropped at function end leaves the browser holding a dangling hook: it fires once or twice from cached state, then stops with no error in any console. This two-fires-then-silent signature is so consistent across teams that it deserves a checklist entry rather than a debugging session. If your listener dies quietly, assume the handle dropped until proven otherwise.
The rules are mechanical once stated plainly. For program-lifetime listeners — scroll handlers, animation-frame loops, interval timers — call cb.forget() to deliberately leak the handle so the browser can invoke it indefinitely, accepting the intentional leak as the price of permanence. For scoped callbacks — one-shot fetch continuations, modal confirmations — store the Closure in an owning struct field or a static slot and drop it explicitly on teardown, which runs deterministic cleanup. For immediate synchronous callbacks that never outlive the call, pass by reference with the scoped borrow pattern so Rust retains ownership throughout. Match the pattern to the lifetime: forget for forever, store for a session, borrow for an instant. Test each by navigating, re-registering, and firing ten times, because single-fire manual tests never catch the drop.
Memory discipline extends beyond closures to every allocation the boundary touches. Allocate inside exported functions, return owned Strings and Vecs, and never hold borrowed views in statics across calls where reallocation or GC can invalidate them. Watch total footprint alongside correctness: a 1.2MB module that lazy-loads after paint behaves like a fast app, while the same bytes blocking the shell behave like a broken one. Run the page under allocation pressure — large temporary arrays between calls — to shake out stale-view reads before users find them. The teams that internalize these two habits, handles with explicit lifetimes and buffers with explicit ownership, stop filing WASM memory bugs entirely and start filing feature requests.
The event-loop interaction has one more subtlety: reentrancy across the boundary. A Rust callback invoked from JavaScript that synchronously calls back into JavaScript — updating state that triggers the same listener — can recurse through glue frames in ways neither borrow checker nor linter predicts. Guard with explicit reentrancy flags or queue-and-drain patterns: set a handling boolean on entry, ignore or coalesce nested invocations, and process queued work after the outer call completes. This is ordinary UI engineering, but the bilingual stack makes the cycle harder to see in either language alone. Draw the call graph across the boundary in the design note for every listener that writes state it also observes.
Teardown discipline closes the lifecycle. Every start_ticker needs a stop_ticker that drops the stored Closure, clears the interval, and releases canvas or audio handles, verified by mounting and unmounting the component fifty times while watching memory in DevTools. Leaked Closures from forgotten forget() calls accumulate across SPA navigations until the tab slows, a failure mode no unit test catches because each mount looks correct in isolation. The teams that never leak treat mount-unmount cycling as a required test, not an afterthought, and they write the teardown before the feature demo rather than after the memory complaint.
Frame scheduling ties closures to rendering: prefer requestAnimationFrame callbacks for visual work over setInterval timers, pass frame timestamps across the boundary as numbers, and keep per-frame Rust work under 8ms so the 60fps budget survives glue plus paint. Batched math that blows the frame budget still janks, just faster per operation.
forget() it for program lifetime or store it in an owner that outlives registration.Deciding What Belongs in WASM: Profiling, Batching, and the 5 Percent Rule
The highest-return WASM decision is which code not to port, and the only honest way to make it is measurement. Open DevTools, record the slow interaction, and read the flame graph: if 80% of a 400ms filter sits in one tight pixel loop, that loop is a port candidate. If time spreads across layout, style recalculation, and network fetches, no compiled language recovers it — the bottleneck is the page, not the math. Port the 5% that burns CPU and keep every router, form handler, and DOM update in JavaScript, where iteration speed and ecosystem depth outweigh raw throughput. A good rule of thumb: candidates are pure functions over buffers with no DOM access, called rarely with large payloads rather than often with tiny ones.
Batching is the implementation of that rule. Design exports that accept whole buffers and return compact summaries: invert(&mut [u8]) instead of invert_pixel(u8), count_dark(&[u8], u8) -> u32 instead of is_dark(u8) in a loop, parse_document(&str) -> String instead of per-token crossings. Each export should do milliseconds of work per call so glue overhead at ~150ns fades into noise. Wrap both the old JS path and the new WASM path in performance.now() harnesses over production-sized inputs — 12MP images, 5MB documents, 100k-row datasets — and require the WASM path to win by at least 2x including boundary costs before it ships. A 9x math win that nets 1.2x end to end is not worth the second language in the stack.
Ship the decision with guardrails so it stays true. Keep pure logic in host-free Rust covered by plain cargo test, where iteration runs roughly ten times faster than browser suites, and reserve wasm-pack test --headless --chrome for boundary and DOM-adjacent behavior. Gate the feature behind a flag that falls back to the JavaScript implementation when the module fails to load, because a filter that degrades to 45ms JS beats a page that breaks at 0ms WASM. Re-profile quarterly: browser JITs improve, payloads shift, and yesterday's hot loop becomes today's cold path. WASM used surgically on measured hot paths is the highest-leverage 5% of a demanding app. WASM used aspirationally across the codebase is a second codebase to maintain for speedups nobody measured.
Rollout strategy is where the 5% rule meets users. Gate every WASM path behind a flag evaluated at load: attempt init(), fall back to the proven JavaScript implementation on any failure, and record which path served each session. This turns a broken nightly CDN fetch or an unsupported browser quirk into a 45ms degraded filter instead of a blank editor, and the flag telemetry tells you exactly what share of traffic benefits from the port. One media team discovered 11% of sessions silently used the fallback for a month due to a corporate proxy stripping .wasm MIME types — a finding that arrived as a dashboard row rather than a support crisis because the fallback existed.
Keep the JS fallback maintained rather than letting it rot. Run it in CI against the same fixtures, budget its performance so degradation stays acceptable, and resist deleting it after the WASM path looks stable. Browsers evolve, corporate proxies misbehave, and the next boundary regression will arrive on a Friday evening when the fallback is the only thing between users and a broken page. Surgical WASM with a living fallback is a performance investment. Surgical WASM with a deleted fallback is a single point of failure wearing a speed costume.
Ownership of the decision matters as much as the technique. Assign one owner to the WASM boundary who reviews every export addition, runs the quarterly re-profile, and holds the fallback alive. Shared ownership of a bilingual boundary means nobody measures, nobody trims, and the 5% quietly grows into 40% nobody can remove. Keep the fallback suite green in CI against identical fixtures, so degraded mode stays a tested path with budgeted speed rather than holiday-only hope for users. Rehearse the fallback quarterly under synthetic CDN failure so the degraded path stays instant when the primary breaks.
The 2M Per-Pixel Calls That Dropped a Photo Editor to 3fps for 9 Days
performance.now() timing assert was added to CI failing any build where the reference 12MP fixture exceeds 50ms, plus a 500KB compressed-size budget check on pkg/ output.- Boundary crossings cost real microseconds: one batched call over a 2M-pixel buffer measured 41ms while 2M single-pixel calls measured 380ms on identical math, so design exports around slices and counts rather than scalars.
- Assert frame-time and bundle-size budgets in CI so the next per-pixel regression or 1.2MB debug build fails a pull request instead of 200 app-store reviews over 9 silent days.
- Lazy-load anything beyond a 500KB first-paint budget after interactivity, because a photo filter users reach in 8 seconds must never block the editor shell users need in 800ms.
expected a WebAssembly.Module or 404s on the .wasm fetch after a fresh clonerustup target list --installed | grep wasm32-unknown-unknown, add it with rustup target add wasm32-unknown-unknown, then rebuild with wasm-pack build --target web from the crate root and verify ls -la pkg/ shows both .wasm and .js glue. Serve over HTTP with python3 -m http.server 8080 and open http://localhost:8080, since ES-module WASM imports refuse file:// URLs. Check Cargo.toml declares crate-type = ["cdylib"] under [lib]; without it cargo emits an rlib no browser can instantiate.performance.now() timestamps and log the delta, then run cargo build --release --target wasm32-unknown-unknown and compare against the batched variant. Fix by redesigning the export to accept &mut [u8] once per frame instead of per pixel: pass the full Uint8Array, compute inside Rust, read results in place. Re-measure; the reference workload should drop from ~380ms toward ~41ms. Keep wasm-pack test --node passing so the contract stays proven outside the browser.let cb = Closure::wrap(Box::new(move || { / handler / }) as Box<dyn FnMut()>), registering with window.set_interval_with_callback_and_timeout_and_arguments_0(cb.as_ref().unchecked_ref(), 1000), then calling cb.forget() for program-lifetime listeners or storing the handle in a static/owner struct. Retest navigation plus re-registration 10 times; if the callback must be cancellable, keep the Closure in an Option field and drop it explicitly on teardown.RuntimeError: unreachable executed with no file, line, or messageconsole_error_panic_hook as a dependency, call console_error_panic_hook::set_once() in your #[wasm_bindgen(start)] or initialization export, rebuild with wasm-pack build --target web, and reload with DevTools open. The next panic logs the full Rust message plus stack to the console. For release builds keep the hook behind a cfg flag or log-level gate so production users get a friendly fallback while your staging build captures traces. Pair with --dev versus --release pkg builds to separate debuggable output from shippable size.ls -la pkg/.wasm and gzip -k pkg/.wasm && ls -la pkg/.wasm.gz to get raw and compressed sizes, then set [profile.release] opt-level = "z" plus lto = true in Cargo.toml and rebuild with wasm-pack build --release --target web. Run wasm-opt -Oz pkg/_bg.wasm -o pkg/*_bg.wasm to squeeze further, confirm the add-two-numbers reference shape if you suspect bindgen bloat, and split the bundle so the shell loads first with import() lazy-loading the heavy codec after interactivity.String or Vec<u8> values: accept &str, compute, return String via format!, and never stash a &str view. Run wasm-pack test --headless --chrome covering interleaved allocate-and-call sequences so stale-view regressions fail in CI rather than on user machines.wasmtime --version and execute WASI builds with wasmtime run target/wasm32-wasip1/release/app.wasm, keeping browser builds on wasm32-unknown-unknown with JS imports and WASI builds on wasm32-wasip1 with std::fs access. List imports with wasm2wat pkg/*_bg.wasm | grep import — DOM or fetch imports prove a browser-only module that Wasmtime cannot satisfy. Gate host-specific code with #[cfg(target_arch = "wasm32")] and keep shared logic in host-free functions covered by plain cargo test.| File | Command / Code | Purpose |
|---|---|---|
| setup.sh | rustup target add wasm32-unknown-unknown | Installing the wasm32-unknown-unknown Target and Scaffolding |
| src | use wasm_bindgen::prelude::*; | wasm-bindgen |
| src | use wasm_bindgen::prelude::*; | Passing Strings, Slices, and Structs Across the JS Boundary |
| build.sh | cargo build --target wasm32-unknown-unknown | Building and Publishing with wasm-pack |
| Cargo.toml | [lib] | Shrinking the Bundle |
| wasi.sh | rustup target add wasm32-wasip1 | Browser vs WASI |
| src | use wasm_bindgen::prelude::*; | Closures, Memory, and Lifetimes |
| bench.mjs | await init(); | Deciding What Belongs in WASM |
Key takeaways
set_once(), iterate on --dev builds, and reproduce under wasm-pack test --node first.Common mistakes to avoid
7 patternsBuilding WASM without the cdylib crate type
Passing complex Rust types directly across the JS boundary
Dropping JS closures too early
forget() for program-lifetime listeners or store the handle in an owner struct, and test navigation plus re-registration 10 times.Holding borrowed views across JS calls
Calling WASM per pixel instead of per buffer
performance.now() assert under 50ms for the reference fixture.Shipping debug-size modules to production first paint
import() after interactivity, and fail CI past 500KB compressed.Targeting the wrong host runtime for the feature
Interview Questions on This Topic
How does Rust code reach the browser as WebAssembly?
Frequently Asked Questions
20+ years shipping production backend systems. Written from production experience, not tutorials.
That's WASM. Mark it forged?
26 min read · try the examples if you haven't