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:

  1. Source Parsing and Type Checking: Rust code is parsed, type-checked, and converted into Intermediate Representation (MIR/HIR).
  2. LLVM Backend: rustc passes the intermediate representation to LLVM.
  3. 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).
  4. Binary Generation: The final output is a .wasm binary 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.