How Rust Targets WebAssembly: Inside the wasm32 Backend: Difference between revisions
Created page with "You’ve probably seen it before: “Compile Rust to WebAssembly and run it in the browser.” Sounds magical, right?
You cargo build --target wasm32-unknown-unknown, and suddenly your Rust code is running inside Chrome’s JavaScript engine. But what really happens between those two steps?
How does Rust — a systems language that talks directly to hardware — suddenly become a safe sandboxed bytecode that the browser can execute? Let’s lift the curtain.
..." |
(No difference)
|
Latest revision as of 16:42, 15 November 2025
You’ve probably seen it before: “Compile Rust to WebAssembly and run it in the browser.”
Sounds magical, right? You cargo build --target wasm32-unknown-unknown, and suddenly your Rust code is running inside Chrome’s JavaScript engine. But what really happens between those two steps? How does Rust — a systems language that talks directly to hardware — suddenly become a safe sandboxed bytecode that the browser can execute? Let’s lift the curtain. We’re going deep into how Rust targets WebAssembly, what actually happens in the wasm32 backend, and why it’s one of the most underrated feats of compiler engineering in modern programming.
The Emotional Truth: Rust and WebAssembly Were Made for Each Other
When Rust and WebAssembly first met, it wasn’t love at first sight. Rust was designed for low-level systems work — drivers, kernels, embedded systems. WebAssembly (WASM) was designed for high-level, portable execution in browsers.
But then developers realized: “Wait, WASM gives me a safe sandbox and zero-cost abstractions. Rust gives me memory safety and zero-cost abstractions. They speak the same language!”
That synergy turned Rust into the number-one language for serious WebAssembly projects — from Figma’s rendering engine to Cloudflare Workers. And at the heart of it all lies the wasm32 backend — a quiet piece of compiler machinery that turns your .rs files into .wasm binaries.
Step 1: The Target Triple — wasm32-unknown-unknown When you tell Rust to compile for WebAssembly, you’re invoking this target: cargo build --target wasm32-unknown-unknown
This triple means:
| Part | Meaning | | --------- | ---------------------------------------- | | `wasm32` | 32-bit WebAssembly architecture | | `unknown` | No OS — just the WebAssembly environment | | `unknown` | No vendor — completely generic |
Rust has several other WASM targets too:
| Target | Description | | --------------------------- | ------------------------------------------------------------- | | `wasm32-unknown-unknown` | Bare-metal WASM (no std, pure core) | | `wasm32-wasi` | WASI (WebAssembly System Interface — like a fake OS for WASM) | | `wasm32-unknown-emscripten` | Legacy target for Emscripten toolchain |
When you use wasm32-unknown-unknown, you’re basically telling the compiler: “Forget the operating system. Just emit raw WebAssembly instructions.”
Step 2: The Compilation Pipeline — From Rust to WASM Bytecode The magic happens in the backend pipeline. Here’s a high-level diagram of how your Rust code flows through the system:
┌──────────────────────────────┐
│ Rust Source (.rs) │
└──────────────┬───────────────┘
│
▼
┌──────────────────────┐
│ HIR / MIR (IRs) │
└──────────┬───────────┘
│
▼
┌───────────────────────────┐
│ LLVM (wasm32 backend) │
└──────────┬────────────────┘
│
▼
┌───────────────────────────┐
│ WebAssembly (.wasm) │
└───────────────────────────┘
Let’s break down those phases.
1. MIR — Rust’s Middle Intermediate Representation After parsing and type checking, Rust converts your functions into MIR (Mid-level IR) — a simpler, SSA-like version of your code that’s independent of platform. Example:
fn add(a: i32, b: i32) -> i32 {
a + b
}
MIR (simplified): bb0: {
_0 = Add(a, b); return;
}
This step is pure Rust logic. Nothing platform-specific happens yet.
2. LLVM IR — The Universal Layer Next, MIR is lowered to LLVM IR — the universal language that all Rust targets share. LLVM is what actually knows how to generate machine code for various targets, including x86_64, ARM, and… WebAssembly. Example (LLVM IR):
define i32 @add(i32 %a, i32 %b) { entry:
%sum = add i32 %a, %b ret i32 %sum
}
This is what the Rust compiler sends to LLVM’s wasm32 backend.
3. wasm32 Backend in LLVM Here’s the star of the show. LLVM’s wasm32 backend transforms the generic IR into WebAssembly bytecode. It’s not trivial — because WebAssembly is a stack machine, not a register machine like x86.
Let’s see the same add function in WebAssembly Text Format (WAT): (func $add (param $a i32) (param $b i32) (result i32)
local.get $a local.get $b i32.add)
Notice what’s different?
- There are no registers, only a stack.
- Each instruction pushes/pops values.
- Control flow is explicit (if, block, loop).
LLVM’s wasm32 backend rewrites SSA-style code into this stack-based format, optimizing along the way (constant folding, dead code elimination, etc.).
Step 3: Linking and Exporting When you build a Rust → WASM binary, Cargo uses wasm-ld (the WebAssembly linker).
It strips away all unnecessary runtime parts, leaving only the exported functions. Example:
- [no_mangle]
pub extern "C" fn greet() {
println!("Hello from Rust!");
}
Run: cargo build --target wasm32-unknown-unknown --release
Output: target/wasm32-unknown-unknown/release/your_app.wasm
To call this from JavaScript: import init, { greet } from './your_app.js';
init().then(() => {
greet();
});
Under the hood, the WASM binary exposes a function named greet that JavaScript can import.
Step 4: Memory and the WebAssembly Sandbox Here’s where things get emotional for systems developers. You don’t get malloc. You don’t get threads. You get one linear memory — a single contiguous array of bytes.
Rust’s allocator (when using wasm-bindgen or wee_alloc) manages that memory manually.
Example (simplified): static mut MEMORY: [u8; 65536] = [0; 65536]; // 64KB
- [no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut u8 {
// Return a pointer into our linear memory
unsafe { MEMORY.as_mut_ptr() }
}
Of course, the real allocator is far smarter — but conceptually, this is what happens. Every pointer in Rust maps to an offset inside WebAssembly’s linear memory.
Step 5: Host Bindings (wasm-bindgen) WASM by itself can’t print, open files, or use DOM APIs — it’s isolated. That’s where wasm-bindgen comes in: it bridges Rust and JavaScript.
Example: use wasm_bindgen::prelude::*;
- [wasm_bindgen]
pub fn greet(name: &str) {
web_sys::console::log_1(&format!("Hello, {}!", name).into());
}
Now you can call this directly from JS: import { greet } from './pkg/hello.js'; greet("World");
Under the hood, wasm-bindgen generates the glue code to pass strings, memory pointers, and function calls across the WASM-JS boundary. Architecture of Rust → WebAssembly Toolchain
┌────────────────────────────┐
│ Rust Source │
└──────────────┬─────────────┘
▼
┌──────────────────────┐
│ rustc Frontend │
│ (Parse, MIR, LLVM) │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ LLVM wasm32 Backend│
└──────────┬───────────┘
▼
┌──────────────────────┐
│ wasm-ld Linker │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ .wasm Binary Output │
└──────────────────────┘
Why Rust Compiles So Well to WASM Here’s the real reason Rust became the language of choice for WebAssembly:
| Rust Feature | WebAssembly Benefit | | --------------------------- | -------------------------------- | | No runtime GC | Small binary size | | Deterministic memory layout | Easy linear memory mapping | | Strict ownership model | Safe across the sandbox boundary | | LLVM backend | Reuses mature wasm32 codegen | | Zero-cost abstractions | Near-native speed |
Rust doesn’t need a garbage collector or JIT like JavaScript. It compiles straight to linear memory operations, meaning your WASM modules can run almost as fast as native code.
Real-World Example: Figma’s Rendering Engine Figma uses Rust compiled to WASM to handle its vector math and rendering logic.
Why? Because JavaScript couldn’t keep up with 60 FPS transforms and zooms. Rust’s predictable performance and WASM’s portability meant the same rendering core could run in the browser and on the desktop — identical logic, shared codebase.
That’s the true power of wasm32. What’s Next for Rust + WASM
The ecosystem is evolving fast:
- WASI adds system calls (files, sockets) to WASM, making it a real OS target.
- Component Model aims to make WASM modules interoperable between languages.
- Async and threading are coming to WASM 2.0.
Rust’s compiler team is already aligning its backend with these new features — especially multi-memory and interface types. We’re heading toward a world where you can build entire applications in Rust, deploy them on the web, and never write a line of JS glue again.
Key Takeaways
| Concept | Description | | --------------- | ---------------------------------------------------- | | `wasm32` Target | The WebAssembly backend for Rust | | LLVM | Translates Rust IR to stack-based WASM bytecode | | Linear Memory | A single contiguous memory block for all allocations | | wasm-bindgen | Bridges Rust functions to JS and the browser | | WASI | Expands WASM beyond browsers into servers and CLIs |
Final Thoughts The wasm32 backend is one of Rust’s quiet triumphs. It takes a language built for bare metal and retargets it to a virtual machine sandbox — without losing performance, safety, or soul.
Rust didn’t just adapt to WebAssembly; it elevated it. Every time you see a silky-smooth Rust-powered Web app, remember that under the hood, the compiler is performing a delicate dance — translating ownership, safety, and lifetimes into stack operations and bytes.
Rust and WebAssembly aren’t just compatible — they’re spiritually aligned.