Rust's WebAssembly Model
WebAssembly (WASM) is a portable, sandboxed bytecode format, and Rust is one of the few systems languages that can target it with almost no code changes to core logic. That combination is why Rust shows up so often in browser-side parsers, codecs, and game engines: it gets near-native performance inside a security boundary designed for untrusted code. Understanding this page's model matters because WASM is not "Rust running in the browser" in the way people often assume - it is Rust compiled to a different machine entirely, one with no operating system, no heap unless you bring one, and no direct line to the DOM unless something builds the bridge for you.
This page is the conceptual anchor for the rest of the WebAssembly section. The
Basics page shows you the commands and boilerplate; this page explains why those
commands exist and what is actually happening underneath wasm-bindgen, the
wasm32-unknown-unknown target, and the WASI alternative.
Summary
- WebAssembly is a stack-based virtual instruction set that Rust compiles to via an LLVM backend, and it runs inside a sandbox that has no ambient access to the host system.
- Insight: Rust's ownership model produces fast, predictable code without a garbage collector, which is exactly the profile a sandboxed VM rewards - but the sandbox also removes assumptions Rust code normally makes about memory, threads, and I/O.
- Key Concepts: linear memory, the wasm32 target, the host/guest boundary, wasm-bindgen glue generation, JsValue, and WASI.
- When to Use: Compute-heavy browser code (parsing, image/audio processing, cryptography), portable plugin systems, and server-side sandboxed execution where you need near-native speed with strong isolation.
- Limitations/Trade-offs: No direct DOM or syscall access without generated glue, every boundary crossing costs a copy or serialization, and the toolchain (bindgen, wasm-pack, wasm-opt) adds a build step Rust developers targeting native platforms don't have.
- Related Topics: linear memory sandboxing, foreign function interfaces, the WASI Component Model, JavaScript interop patterns.
Foundations
WebAssembly is not a CPU instruction set the way x86-64 or ARM64 are. It is a
stack-based bytecode designed to be verified quickly and executed by a host VM -
a browser's JS engine, Wasmtime, Wasmer, or an edge runtime. When you compile Rust
"to WebAssembly," the compiler is not producing machine code for your laptop's chip;
it is producing .wasm bytecode that some other program will load, validate, and
execute. That host program is doing the actual work of turning bytecode into
instructions your CPU runs, usually via a JIT.
Rust reaches this format through one of its wasm32 targets. The two you will
meet most are wasm32-unknown-unknown, which assumes no operating system at all,
and wasm32-wasi (and its successor wasm32-wasip2), which assumes a narrow,
capability-based system interface called WASI. "Unknown-unknown" is a blunt name
but an honest one: unknown vendor, unknown OS. There is no filesystem, no sockets,
no threads, and no std::env unless something explicitly wires those in. This is
the single biggest mental shift for a Rust developer coming from native or server
targets - most of what std normally does for you assumes an OS underneath, and
WASM's default target has none.
The memory model reinforces the same point. A WASM module owns one contiguous block
called linear memory - conceptually a big Vec<u8> that the module can read,
write, and grow, but that the host can also inspect from the outside. Rust's own
allocator sits on top of this linear memory exactly as it would sit on top of a
native heap, so Box, Vec, and String all work as expected inside Rust code.
What does not carry over automatically is any notion of pointers being meaningful to
whatever is on the other side of the sandbox wall. A &str is real and safe inside
Rust; to JavaScript it is just an offset and a length into a byte array it cannot
interpret without help.
A simple analogy: think of a WASM module as a locked shipping container, not a
guest in your house. Nothing gets in or out except through the doors the container
was built with. Rust code inside that container behaves normally - borrow checking,
ownership, panics, all of it work exactly as they do natively - but nothing outside
the container can see or touch that world without a door built for the purpose.
wasm-bindgen is the tool that builds those doors.
Mechanics & Interactions
The host/guest boundary is the organizing idea for everything else in this section. The "guest" is your compiled WASM module; the "host" is whatever embeds and calls it, most commonly a JavaScript runtime in a browser. Communication across that boundary is deliberately narrow: the WASM spec itself only allows passing numbers (integers and floats) as function arguments and return values. Strings, structs, arrays, and JS objects are not native WASM concepts - they have to be encoded into numbers (pointers and lengths into linear memory) and decoded again on the other side.
This is what wasm-bindgen actually automates. It is not a runtime bridge that
intercepts calls at execution time; it is a code generator that runs at build
time and produces two matching halves: Rust-side glue that packs/unpacks values into
linear memory, and a JavaScript-side .js file that knows how to read that memory
and present it as normal JS values (strings, objects, classes). When you annotate a
function with #[wasm_bindgen], you are asking the macro to generate this
marshaling code for you instead of hand-writing pointer arithmetic. wasm-pack then
packages the compiled .wasm plus the generated .js/.d.ts files into something
that looks and imports like a normal npm module.
Every value that crosses this boundary is either copied or represented through
JsValue, an opaque handle Rust holds that actually lives in a table on the
JavaScript side. Passing a String from Rust to JS copies bytes out of linear
memory into a new JS string; passing a JS object into Rust wraps it as a JsValue
that Rust cannot inspect directly without further glue (js_sys, serde-wasm-bindgen).
This is why interop code that crosses the boundary frequently - one small call per
DOM event, for example - tends to become the actual bottleneck in WASM apps, even
though the Rust computation itself is fast. The cost is not computing; it is
translating.
A short illustration of what the macro is standing in for conceptually:
// What #[wasm_bindgen] generates glue for, conceptually:
// JS calls greet(ptr, len) with a pointer/length pair into linear memory.
// Rust reads those bytes back out as a &str before running real logic.
#[no_mangle]
pub extern "C" fn greet(ptr: *const u8, len: usize) -> *mut u8 {
let name = unsafe {
std::str::from_utf8_unchecked(std::slice::from_raw_parts(ptr, len))
};
let out = format!("Hello, {name}!");
Box::into_raw(out.into_boxed_str().into()) as *mut u8
}wasm-bindgen writes something like this for you, plus the matching JavaScript that
allocates memory, writes the string bytes, and reads the result back out - so you
never touch raw pointers in ordinary application code.
The other consequence of the sandbox model is what disappears. Threads, in the
classic OS sense, do not exist unless the host opts into SharedArrayBuffer and a
crate like wasm-bindgen-rayon wires it up; by default a WASM module is
single-threaded. Panics do not unwind into a stack trace the browser understands
by default either - an unhandled Rust panic traps the whole instance, which is why
console_error_panic_hook exists as a translation layer, not a Rust feature. And
syscalls - file access, networking, environment variables - simply are not there
under wasm32-unknown-unknown; WASI exists specifically to give a
standardized, capability-scoped version of those syscalls back for non-browser
hosts (CLIs, edge functions, plugin runtimes) without reintroducing full OS trust.
Advanced Considerations & Applications
The sandboxing model is a security feature, not an accident of the toolchain. A WASM module can only affect the world through the exports the host chose to call and the imports the host chose to provide - there is no ambient authority, which is why WASM (and especially WASI's capability model) gets used for plugin systems where the plugin author is not fully trusted. This is the same property that makes debugging harder: a crash inside the sandbox can't corrupt host memory, but it also can't give you a familiar native stack trace without deliberate tooling support.
Performance in this model has a specific shape. Once code is inside the sandbox and
running, Rust's compiled WASM is close to native speed for CPU-bound work: no GC
pauses, no dynamic dispatch overhead beyond what you wrote, and LLVM's usual
optimizations still apply before the wasm32 backend even runs. The overhead lives
almost entirely at the boundary and in module startup - parsing/validating the
.wasm bytecode, instantiating linear memory, and each marshaled call. This is why
size optimization and boundary-crossing frequency dominate the practical advice in
this section far more than "make the Rust code itself faster."
The ecosystem has also been moving past hand-written wasm-bindgen glue toward the
WASI Component Model, which defines interfaces (WIT) that can describe richer
types - records, variants, resources - without every language reinventing its own
JS-specific bridge. wasm-bindgen remains the pragmatic choice for browser/JS
targets specifically; the Component Model targets a broader, host-agnostic future
where WASM modules compose with each other directly, not just with JavaScript.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
wasm-bindgen + wasm32-unknown-unknown | Mature JS interop, rich npm tooling via wasm-pack | Browser/JS-specific; boundary calls have marshaling cost | Browser apps calling JS APIs directly |
wasm32-wasi / wasip2 | Standardized syscalls, portable across non-browser hosts | Async and threading support still maturing | CLIs, edge functions, server-side sandboxed plugins |
Raw extern "C" exports, no bindgen | Smallest possible glue, no generated JS | You hand-roll all marshaling and memory management | Minimal embeddings with a custom host, not JS |
Common Misconceptions
- "WASM makes Rust run in the browser like a native app." - It runs inside a strict sandbox with no ambient access to the DOM, filesystem, or OS; every capability has to be explicitly wired in through generated glue or WASI.
- "wasm-bindgen is a runtime that intercepts calls." - It is a build-time code generator; by the time your module runs, the glue is just ordinary compiled Rust and JS reading/writing linear memory.
- "Passing data across the boundary is basically free, like a function call." - Non-numeric values are copied or serialized into linear memory on every crossing, which is why chatty boundary-crossing APIs become the real bottleneck.
- "Rust's ownership and borrowing guarantees extend to the JS side." - They only hold inside the WASM module; once a value crosses into
JsValueterritory, JS's garbage-collected, mutable-reference world takes over. - "wasm32-unknown-unknown and wasm32-wasi are basically the same target with different names." - They represent fundamentally different host contracts: one assumes nothing, the other assumes a defined, capability-scoped system interface.
- "A Rust panic in WASM behaves like a panic anywhere else." - By default it traps the entire module instance with an opaque error; readable diagnostics require an explicit panic hook.
FAQs
What does "compiling to WebAssembly" actually produce?
A .wasm binary containing stack-based bytecode plus a table of exports/imports, not
native machine code for your CPU. A host runtime loads, validates, and executes it.
Why doesn't Rust's std library just work out of the box on wasm32-unknown-unknown?
Most of std assumes an operating system underneath it for things like files,
threads, and environment variables. wasm32-unknown-unknown provides none of that,
so those std paths are either stubbed out or unavailable.
Is wasm-bindgen part of the WebAssembly spec?
No. WebAssembly itself only supports passing numbers across function boundaries.
wasm-bindgen is a Rust-ecosystem tool that generates the marshaling code needed to
present richer JS types on top of that numeric-only foundation.
How does data actually get from a Rust String to a JavaScript string?
The Rust side writes the string's bytes into linear memory and returns a pointer/length pair; generated JS glue reads those bytes back out of the module's memory buffer and decodes them into a native JS string. It is a copy, not a share.
Why is my WASM function slower than expected even though the Rust logic is simple?
The cost is usually in the boundary crossing, not the computation. Each call that passes non-trivial data pays a marshaling cost; batching work into fewer, larger calls is the standard fix.
Can WASM modules access the filesystem or network directly?
Not under wasm32-unknown-unknown. Under WASI, they can, but only through
explicitly granted capabilities (like preopened directories), never through ambient
OS access the way a native binary has.
Do threads work in Rust-compiled WASM?
Not by default; a module is single-threaded unless the host supports
SharedArrayBuffer and the build opts into a threading crate such as
wasm-bindgen-rayon.
What happens when Rust code panics inside a WASM module?
The instance traps and becomes unusable without a hook installed. Development builds
typically install console_error_panic_hook to translate the trap into a readable
browser console error.
Is wasm32-wasi the same thing as running Rust natively, just sandboxed?
Conceptually close but not identical: WASI exposes a capability-based subset of system calls, not the full POSIX surface, so code that assumes arbitrary OS access can still fail even when it compiles.
When should I reach for the Component Model instead of wasm-bindgen?
When you need WASM modules to interoperate with each other or with non-JS hosts using richer shared types, rather than bridging specifically to a JavaScript environment.
Does ownership and borrowing still matter once code targets WASM?
Yes, entirely, inside the Rust module itself. The compiler enforces the same rules it always does; what changes is that those guarantees stop at the boundary; the other side is JS's memory model, not Rust's.
Why do WASM builds need a separate optimization pass like wasm-opt?
LLVM's wasm32 backend optimizes reasonably well, but wasm-opt applies
WASM-specific passes (from the Binaryen toolkit) on the already-compiled binary,
catching size and instruction-count wins the Rust compiler doesn't target directly.
Related
- WebAssembly Basics - hands-on walkthrough of targets, builds, and the toolchain this page explains conceptually
- wasm-bindgen - the generated glue and type mapping across the JS/Rust boundary in practice
- WASI - the capability-based system interface for non-browser WASM hosts
- Performance & Size - why boundary crossings and binary size dominate real-world WASM performance
- WASM Components & the Component Model - the host-agnostic evolution beyond hand-written bindgen glue
Stack versions: This page was written for Rust 1.97.0 (edition 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, and Polars 0.46+.