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
- [ ]
rustupinstalled and a stable toolchain (rustup default stable, 1.78+ recommended) - [ ] The
wasm32-unknown-unknowncompilation target added (rustup target add wasm32-unknown-unknown) - [ ]
wasm-pack0.13+ onPATH(cargo install wasm-packor a pinned release binary) - [ ]
wasm-bindgen-climatching your crate’swasm-bindgenversion (version skew is a hard error) - [ ] The WebAssembly Binary Toolkit (
wabt) forwasm-objdump/wasm-validate, plus Binaryen forwasm-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.
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-bindgenversion mismatch. If thewasm-bindgencrate inCargo.lockdoes not match the installedwasm-bindgen-cli, you getit 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. Lettingwasm-packmanage the CLI avoids this entirely.- A dependency pulls in
std::fsorlibc. Compilation dies withcannot find crate for stdor unresolved symbols. Audit withcargo tree -e featuresand 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 asRuntimeError: unreachablewith no source — register the hook in yourstartfunction. - Randomness fails at runtime. A crate that uses
getrandomwithout itsjsfeature compiles but throws at runtime because there is no OS entropy source. Addgetrandom = { 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 inlinear 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
.wasmserved astext/plain;WebAssembly.instantiateStreamingthrowsIncorrect 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
- Best practices for wasm-pack configuration —
profile metadata,
wasm-optflag tuning, bundler conflicts, and reproducible CI builds. - Choosing between Emscripten, wasm-pack, and TinyGo — a decision guide comparing the three toolchains by language, output size, and interop.
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.
Related
- wasm-bindgen deep dive — reading and optimizing the generated glue.
- ESM bindings & module generation — packaging the typed wrapper for your app.
- Wasm optimization flags & size reduction — the full
wasm-optand profile flag reference. - C/C++ to Wasm with Emscripten — the parallel pipeline for native C and C++ code.
- Local development server configurations — serving
.wasmwith the right MIME type and headers.
← Back to Compilation Pipelines & Toolchain Setup