How Rust Compiles to WebAssembly with wasm-bindgen
This article explores the technical pipeline of compiling Rust code
into WebAssembly (Wasm) and bridging the communication barrier between
Rust and JavaScript. It covers how the Rust compiler targets the Wasm
runtime, the type limitations of raw WebAssembly, and how the
wasm-bindgen tool facilitates seamless data transfer,
function calls, and object sharing across the language boundary.
The Rust to WebAssembly Compilation Pipeline
Rust is uniquely suited for WebAssembly because it does not require a
garbage collector or a heavy runtime. The compilation process relies on
the standard Rust compiler (rustc) and the LLVM
backend:
- Source Parsing and Type Checking: Rust code is parsed, type-checked, and converted into Intermediate Representation (MIR/HIR).
- LLVM Backend:
rustcpasses the intermediate representation to LLVM. - Targeting Wasm: By setting the compilation target
to
wasm32-unknown-unknown, LLVM compiles the code into WebAssembly bytecode instead of native machine code (such as x86 or ARM). - Binary Generation: The final output is a
.wasmbinary file containing structured modules, memory definitions, and instructions executable by any standard WebAssembly engine.
The Interoperability Challenge
By default, WebAssembly has a very limited type system. It natively
understands only basic numeric types: 32-bit and 64-bit integers
(i32, i64) and 32-bit and 64-bit floats
(f32, f64).
Raw WebAssembly cannot natively receive or return complex JavaScript types such as strings, objects, arrays, DOM nodes, or arbitrary functions. Instead, WebAssembly programs operate on a single contiguous array of raw bytes known as linear memory. For JavaScript and Rust to exchange complex data, that data must be manually serialized into linear memory and read out by the other side.
How wasm-bindgen
Bridges the Gap
wasm-bindgen is both a Rust library and a CLI tool that
automates two-way communication between WebAssembly and JavaScript. It
generates a JavaScript wrapper module alongside the compiled
.wasm binary to translate high-level types
automatically.
1. Data Marshalling via Linear Memory
When passing a high-level type like a String from Rust
to JavaScript: * The Rust side allocates space in Wasm linear memory,
writes the UTF-8 encoded bytes into it, and returns the memory pointer
and byte length (both i32 values) to JavaScript. * The
generated JavaScript glue code receives the pointer and length, uses the
browser’s native TextDecoder API to read the slice from
WebAssembly memory, and constructs a native JavaScript string.
When JavaScript sends a string to Rust, the process runs in reverse:
JavaScript uses TextEncoder to write bytes into memory
allocated by the Wasm module, passing the memory address to the Rust
function.
2. Exporting Rust to JavaScript
Developers use the #[wasm_bindgen] attribute macro to
mark Rust functions, structs, and methods that should be visible to
JavaScript:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}The macro generates the necessary boilerplate inside the WebAssembly
binary and emits metadata that the wasm-bindgen-cli uses to
build the corresponding JavaScript interface.
3. Importing JavaScript into Rust
wasm-bindgen also enables Rust to call JavaScript
functions and access web APIs:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}This allows Rust to invoke console.log directly.
wasm-bindgen manages the function index tables and handle
passing under the hood.
4. Handling Complex
Objects with JsValue
To pass arbitrary JavaScript objects into Rust without converting
them, wasm-bindgen uses a special wrapper type called
JsValue. Instead of passing the entire object into Wasm
memory, the JavaScript wrapper retains the object in an internal heap
and passes an integer index (a handle) to Rust. When Rust wants to
manipulate the object, it passes the index back to JavaScript,
maintaining proper object references without violating memory
safety.
The Build and Packaging Process
In practice, the entire workflow is typically orchestrated using
wasm-pack: 1. wasm-pack compiles the Rust code
using cargo build --target wasm32-unknown-unknown. 2. It
runs the wasm-bindgen CLI tool on the resulting binary to
generate the JavaScript wrapper files and TypeScript type definition
files (.d.ts). 3. It packages the output into a directory
ready to be imported directly by bundlers (like Webpack, Vite, or
Rollup) or published directly to npm.