Rust to Wasm Compilation Guide

Rust is the most popular source language for WebAssembly because its zero-cost abstractions, lack of a garbage collector, and strict ownership model map cleanly onto a small, fast .wasm binary. But the path from a .rs file to a module the browser will instantiate runs through three distinct tools — the Rust compiler targeting wasm32-unknown-unknown, wasm-bindgen to generate the JavaScript glue, and wasm-pack to orchestrate both and package the result. Getting them aligned, pinned, and tuned is the difference between a 12 KB module that streams in under a frame and a 400 KB blob that drags your first paint. This guide walks the whole pipeline end to end, with real commands, the size and speed tradeoffs that actually matter, and how to verify what you shipped.

Prerequisites

  • [ ] rustup installed and a stable toolchain (rustup default stable, 1.78+ recommended)
  • [ ] The wasm32-unknown-unknown compilation target added (rustup target add wasm32-unknown-unknown)
  • [ ] wasm-pack 0.13+ on PATH (cargo install wasm-pack or a pinned release binary)
  • [ ] wasm-bindgen-cli matching your crate’s wasm-bindgen version (version skew is a hard error)
  • [ ] The WebAssembly Binary Toolkit (wabt) for wasm-objdump / wasm-validate, plus Binaryen for wasm-opt

How the pieces fit together

The mental model that prevents most confusion: cargo produces a raw .wasm that knows nothing about JavaScript strings or objects — its exports speak only i32, i64, f32, and f64. wasm-bindgen then runs as a post-processing pass over that binary, rewriting it and emitting a .js shim plus a .d.ts so the boundary becomes type-safe. wasm-pack is the conductor: it invokes cargo build, then wasm-bindgen, then wasm-opt, and finally writes an npm-ready pkg/ directory. You can run each stage by hand, but in practice you let wasm-pack drive.

flowchart LR A[src/lib.rs<br/>#wasm_bindgen] --> B[cargo build<br/>--target wasm32-unknown-unknown] B --> C[raw .wasm<br/>i32/i64/f32/f64 only] C --> D[wasm-bindgen<br/>rewrite + glue] D --> E[wasm-opt<br/>Binaryen passes] E --> F[pkg/<br/>_bg.wasm + .js + .d.ts] F --> G[bundler / browser import]

The import object your application eventually passes to WebAssembly.instantiate is assembled by that generated .js shim, not by you — which is exactly why understanding what wasm-bindgen emits pays off when you profile or debug. The wasm-bindgen deep dive reads the generated shim line by line; here we focus on producing it correctly.

Two properties of this pipeline drive almost every decision that follows. First, the raw .wasm is language-agnostic at the boundary — once cargo is done, nothing downstream knows or cares that the source was Rust, only that there are exported functions taking and returning numbers and an exported memory. That is why wasm-bindgen has to rewrite the binary rather than merely wrap it: it injects helper exports such as __wbindgen_malloc and __wbindgen_free so the JavaScript side can allocate inside the module’s heap. Second, every transformation is lossy in one direction — wasm-opt can rename, merge, and delete functions, so the only export names you can rely on after the full pipeline are the ones wasm-bindgen deliberately preserves. Keep that in mind whenever an export “disappears”: it was almost certainly eliminated as unreachable, not corrupted.

Step-by-step workflow

1. Provision the toolchain deterministically

Pin the toolchain so every developer and CI runner compiles identical bytes. A rust-toolchain.toml checked into the repo is the most reliable mechanism.

rustup default stable
rustup target add wasm32-unknown-unknown
cargo install wasm-pack@0.13.1
# rust-toolchain.toml
[toolchain]
channel = "1.82.0"
targets = ["wasm32-unknown-unknown"]

2. Declare a cdylib crate

The wasm32-unknown-unknown target has no operating system and no C standard library, so any dependency reaching for std::fs or std::net will fail to link. Declare cdylib so the linker emits a flat module with a C-compatible export table rather than a Rust rlib. Keep rlib alongside it if you want cargo test to run your logic natively.

# Cargo.toml
[package]
name = "wasm-core"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = "0.2.92"
console_error_panic_hook = { version = "0.1", optional = true }

3. Write an exported function

Annotate the boundary with #[wasm_bindgen]. The start attribute runs initialization at instantiation time — the idiomatic place to install a panic hook so Rust panics show a real stack trace in DevTools instead of a bare unreachable trap.

use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn init() {
    console_error_panic_hook::set_once();
}

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

4. Catch ABI problems before the full build

A cargo check against the Wasm target surfaces cannot find crate for std or undefined reference to __rust_alloc long before wasm-pack runs, and it is far faster.

cargo check --target wasm32-unknown-unknown
cargo tree -e features --target wasm32-unknown-unknown

The most common cause of a failed check at this stage is a transitive dependency that assumes an operating system — a logging crate that opens a file, a random-number crate that reads /dev/urandom, an HTTP client that opens a socket. The Wasm target has none of those, so the link step fails with unresolved symbols. cargo tree -e features shows you the dependency path that pulled the offender in; the usual fix is default-features = false plus an explicit feature list, or swapping to a Wasm-aware alternative (for example getrandom with its js feature so randomness comes from the browser’s crypto API rather than a syscall).

5. Build the package

One command compiles, runs wasm-bindgen, optimizes with wasm-opt, and writes pkg/. Pick the --target that matches your consumer — bundler for Vite/Webpack/Rollup, web for a bundler-free ESM import, nodejs for synchronous server-side loading.

wasm-pack build --target bundler --release

The emitted directory is npm-publishable as-is:

pkg/
├── wasm_core_bg.wasm    # optimized binary
├── wasm_core.js         # ESM glue (the import object lives here)
├── wasm_core_bg.wasm.d.ts
├── wasm_core.d.ts       # typed boundary
└── package.json

A concrete JS/Wasm binding

This is what wasm-bindgen saves you from hand-writing. The generated glue allocates space in the module’s linear memory, copies the UTF-8 bytes of the string in, calls the raw export with a (ptr, len) pair, then reads the returned (ptr, len) back out and rebuilds a JavaScript string — freeing both allocations along the way. Your application code never sees any of that:

import init, { greet } from "./pkg/wasm_core.js";

await init();                 // fetch + instantiate, wire up the import object
console.log(greet("Ada"));    // "Hello, Ada!" — strings marshaled for you

If you instead want the raw, glue-free view of the same idea — writing bytes into exported memory and passing offsets yourself — that lower-level ABI is the subject of JS/Wasm interop & memory management. The generated wrapper is also what feeds your ESM bindings & module generation pipeline, so the typed .js is what your app actually imports.

For richer boundaries, wasm-bindgen does more than strings. You can export a struct as a JavaScript class, where each method becomes a call that passes the struct’s pointer as a hidden first argument, and the object’s lifetime is tied to a JavaScript handle you must .free() (or let the FinalizationRegistry collect). You can return a Result<T, JsError> and have failures surface as thrown JavaScript exceptions. And with wasm-bindgen-futures, an async fn becomes a function returning a real Promise. Each of these conveniences expands the generated glue, so there is a direct line between how expressive your boundary is and how many bytes of .js ship — a tradeoff worth watching when binary size is tight.

#[wasm_bindgen]
pub struct Counter { value: i32 }

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter { Counter { value: 0 } }

    pub fn increment(&mut self) -> i32 {
        self.value += 1;
        self.value
    }
}
import init, { Counter } from "./pkg/wasm_core.js";
await init();
const c = new Counter();   // allocates the struct in linear memory
c.increment();             // -> 1
c.free();                  // release the wasm-side allocation

Optimization flags & tradeoffs

The release profile in Cargo.toml governs how the Rust compiler emits code; wasm-opt governs the post-pass. The combination below is the standard size-first configuration:

[profile.release]
opt-level = "z"      # optimize for size; "s" is a middle ground, 3 is speed-first
lto = true           # link-time optimization across crates
codegen-units = 1    # one unit lets LTO see everything — best size, slowest compile
panic = "abort"      # drop unwinding tables; smaller, but no catch_unwind
strip = true         # remove symbol names and debug info

Concrete numbers from a small image-filter crate (your mileage varies, but the shape holds):

Configuration .wasm size Relative compute
opt-level = 3, no LTO ~96 KB 1.00× (fastest)
opt-level = "s", lto = true ~61 KB ~0.97×
opt-level = "z", lto = true, codegen-units = 1 ~52 KB ~0.90×
above + wasm-opt -Oz ~41 KB ~0.90×

So opt-level = "z" typically costs 5–15% throughput on compute-bound code in exchange for a meaningfully smaller binary. For cryptography or pixel-pushing where every millisecond counts, prefer opt-level = 3; for a module on the critical loading path, prefer "z". The deeper flag reference, including which Binaryen passes do what, lives in Wasm optimization flags & size reduction.

The other lever is the allocator. By default wasm-pack links dlmalloc, which is robust and handles fragmentation but adds roughly 10–20 KB. The historically popular wee_alloc trims about 10 KB off small modules but is now unmaintained, single-threaded, and prone to leaking under fragmentation — only reach for it on a tiny, allocation-light module, and benchmark rather than assume:

[dependencies]
wee_alloc = "0.4"   # ~10 KB smaller, but unmaintained — measure before adopting
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;

The honest default in 2026 is to keep dlmalloc and spend your size budget on opt-level, dead-code elimination, and avoiding heavyweight dependencies — the allocator is rarely the biggest line item.

A few flags interact in ways worth spelling out. codegen-units = 1 forces the compiler to optimize the whole crate as a single unit, which is what lets LTO inline aggressively across module boundaries; the cost is that compilation can no longer be parallelized, so a clean build gets noticeably slower. For a small crate that is seconds; for a large one it can be minutes, so many teams keep codegen-units = 16 during development and switch to 1 only for release artifacts. panic = "abort" removes the unwinding tables the compiler would otherwise emit, but it also means std::panic::catch_unwind no longer works — fine for browser modules, which cannot recover from a panic anyway, but a real constraint if you were relying on it. And strip = true removes the symbol names that tools like twiggy and the DevTools “Sources” panel use to label functions, so strip in release but keep symbols when you are actively profiling size.

When Rust is the right source language

Before sinking time into this pipeline, it is worth being honest about when it earns its keep. Rust shines for WebAssembly when you need predictable, GC-free latency — audio processing, game loops, cryptography, parsers, image and video codecs — because the absence of a garbage collector means no unexpected pauses and a binary that contains only your logic. It also shines when the JavaScript-facing API is rich enough that hand-marshaling would be painful, since wasm-bindgen generates the typed boundary for free. Where Rust is a poorer fit is glue-heavy code that mostly shuffles data between JavaScript objects and the DOM: every crossing pays marshaling cost, and if the actual computation is small, you may spend more on the boundary than you save on the compute. If your code is already written in C or C++, or in Go, the calculus shifts entirely toward a different toolchain — which is exactly the comparison drawn in choosing between Emscripten, wasm-pack, and TinyGo. The honest rule: reach for Rust-to-Wasm when there is a compute-bound core worth moving off the JavaScript main thread, not as a default for every module.

Gotchas & failure modes

  • wasm-bindgen version mismatch. If the wasm-bindgen crate in Cargo.lock does not match the installed wasm-bindgen-cli, you get it looks like the Rust project used to create this Wasm file was linked against a different version of wasm-bindgen. Fix by aligning them: cargo install wasm-bindgen-cli --version 0.2.92. Letting wasm-pack manage the CLI avoids this entirely.
  • A dependency pulls in std::fs or libc. Compilation dies with cannot find crate for std or unresolved symbols. Audit with cargo tree -e features and disable default features that drag in OS code (features = ["..."], default-features = false).
  • Missing panic hook. Without console_error_panic_hook, a panic surfaces in DevTools as RuntimeError: unreachable with no source — register the hook in your start function.
  • Randomness fails at runtime. A crate that uses getrandom without its js feature compiles but throws at runtime because there is no OS entropy source. Add getrandom = { version = "0.2", features = ["js"] } so it draws from the browser’s crypto API.
  • Forgetting to .free() exported structs. A #[wasm_bindgen] struct returned to JavaScript owns an allocation in linear memory. If you drop the JavaScript handle without calling .free(), that memory leaks until the page reloads — long-running apps must free explicitly.
  • Wrong MIME type when serving. Browsers reject a .wasm served as text/plain; WebAssembly.instantiateStreaming throws Incorrect response MIME type. Expected 'application/wasm'. Configure the dev server, which the local development server configurations guide covers.

Verification

Never trust a build you have not inspected. After wasm-pack build, validate structural integrity and read the export table to confirm your functions survived dead-code elimination:

wasm-validate pkg/wasm_core_bg.wasm
wasm-objdump -x pkg/wasm_core_bg.wasm | grep -A20 'Export\['

A healthy module shows your exported functions plus the memory export:

Export[3]:
 - func[12]  -> "greet"
 - func[8]  -> "init"
 - memory[0] -> "memory"

If an export you expected is missing, it was eliminated as dead code — confirm it carries #[wasm_bindgen] and is pub. To inspect the actual instructions or hunt for bloat, dump the text format with wasm2wat pkg/wasm_core_bg.wasm | less.

For size investigations specifically, twiggy is the sharpest tool — it attributes every byte of the binary to a function or data section, so you can see exactly what is costing you. Running twiggy top -n 20 pkg/wasm_core_bg.wasm ranks the twenty heaviest items; a surprise entry there (a formatting routine, a panic message table, an unused dependency) is usually the fastest size win available. wasm-validate belongs in CI as a gate: a binary that fails validation will fail to instantiate in the browser with a CompileError, and catching that in the build is far cheaper than catching it as a production incident.

In this guide

Frequently Asked Questions

Why wasm32-unknown-unknown and not wasm32-wasi? The unknown-unknown target builds a bare module with no syscalls, which is what browsers want — they provide capabilities through the import object, not a POSIX layer. wasm32-wasi targets a WASI runtime (Wasmtime, Node’s WASI shim) and assumes a filesystem and clock; use it for server-side or CLI Wasm, not browser delivery.

Do I have to use wasm-pack, or can I run the tools by hand? You can run cargo build --target wasm32-unknown-unknown --release then wasm-bindgen target/.../wasm_core.wasm --out-dir pkg --target bundler then wasm-opt yourself. wasm-pack just chains those with sane defaults and writes package.json. Hand-running is useful when you need a non-standard step in the middle.

Why is my “hello world” module still tens of kilobytes? Most of it is the allocator and any formatting machinery format!/println! pulls in. Strip unused features, prefer core/alloc over std patterns where you can, run wasm-opt -Oz, and check the size breakdown with twiggy top pkg/wasm_core_bg.wasm.

Can I avoid the JavaScript glue entirely? Yes — skip wasm-bindgen and write raw extern "C" exports, then call them with hand-written JavaScript that reads and writes linear memory directly. You trade ergonomics and type safety for a smaller binary and full control over the ABI.

How do I expose async Rust to JavaScript? Mark the function async and add wasm-bindgen-futures; wasm-bindgen turns the returned Rust Future into a JavaScript Promise, so await yourFn() works on the JS side. Inside, use JsFuture::from(...) to await JavaScript promises (like fetch). Be aware that the work still runs on whatever thread called it — for CPU-bound async, move the module into a Web Worker.

Does the choice of --target change the .wasm itself? No — the binary is identical across web, bundler, and nodejs. Only the generated .js glue and package.json differ in how they locate and load that binary. You can rebuild for a different consumer by re-running wasm-pack build --target ... without touching your Rust code.

← Back to Compilation Pipelines & Toolchain Setup