How to Use wasm-bindgen for Rust-JavaScript Interop

Web
Use wasm-bindgen to compile Rust to WebAssembly and automatically generate JavaScript bindings for seamless interop.

The missing translator

You spent three hours writing a blazing-fast image filter in Rust. You compile it to WebAssembly, drop the .wasm file into your web project, and try to call it from JavaScript. The browser throws a type error. Or worse, the function returns garbage because JavaScript passed a string and Rust expected a memory buffer. You have the logic, and you have the web page. What you're missing is the translator.

WebAssembly is a binary format designed for performance and portability. It speaks in raw bytes, pointers, and linear memory. JavaScript speaks in dynamic objects, garbage-collected strings, and promises. They do not understand each other. wasm-bindgen is the bridge. It analyzes your Rust code, generates the necessary glue code, and produces a JavaScript module that makes your Rust functions look like native JavaScript functions. You get type safety, automatic memory management, and seamless interop without writing FFI boilerplate.

How wasm-bindgen bridges the gap

Think of WebAssembly as a sealed black box. It has inputs and outputs, but the wires are raw integers. When you call a WASM function, you pass numbers. If you want to pass a string, you have to allocate memory in the WASM heap, copy the bytes there, and pass a pointer and length. When the function returns, you have to read the bytes back and free the memory. Doing this manually is error-prone and tedious.

wasm-bindgen automates this process. It inspects your Rust types and generates JavaScript wrappers that handle the conversion. When you pass a JavaScript string to a Rust function, the wrapper allocates memory, copies the string, calls the WASM function, reads the result, and frees the memory. You never see the pointers. The tool also generates TypeScript definitions, so your editor provides autocomplete for your Rust exports.

The magic happens in two steps. First, you compile your Rust crate to a raw WebAssembly binary. Second, you run the wasm-bindgen CLI tool on that binary. The tool reads the exports, generates a .js file with the glue code, and produces a .wasm file optimized for the web. The JavaScript file is what you import in your browser code.

Minimal example

Start with a standard Rust library crate. Add wasm-bindgen as a dependency and configure the crate type. The cdylib type is mandatory. Without it, cargo produces a static library instead of a WebAssembly module.

[dependencies]
wasm-bindgen = "0.2"

[lib]
crate-type = ["cdylib"]

[profile.release]
lto = true

Enable link-time optimization in release mode. WASM binaries benefit significantly from LTO, which shrinks the output size by eliminating dead code across crate boundaries. Size matters on the web.

Write a simple function and mark it with the #[wasm_bindgen] attribute. This attribute tells the tool to export the function and generate the necessary bindings.

use wasm_bindgen::prelude::*;

/// Greets a user by name.
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    // Rust handles the string formatting.
    // wasm-bindgen handles the memory conversion.
    format!("Hello, {}!", name)
}

The function signature looks like normal Rust code. The &str input and String output are supported types. wasm-bindgen knows how to convert JavaScript strings to Rust strings and back. The tool generates a JavaScript function greet(name) that you can call directly.

The build pipeline

The build process has two distinct phases. First, compile the Rust code to WebAssembly. You need the wasm32-unknown-unknown target installed.

# Compile to WASM
cargo build --target wasm32-unknown-unknown --release

This produces a .wasm file in the target directory. This binary contains the machine code but no JavaScript integration. Next, run the wasm-bindgen CLI tool to generate the bindings.

# Generate JS bindings
wasm-bindgen target/wasm32-unknown-unknown/release/your_crate.wasm --out-dir pkg

The tool creates a pkg directory containing a .js file, a .wasm file, and a .d.ts file for TypeScript. The JavaScript file contains the initialization logic and the wrapper functions.

In your JavaScript code, import the module and initialize it. WASM initialization is asynchronous because the binary might need to be fetched over the network.

// Import the init function and exported Rust functions.
import init, { greet } from './pkg/your_crate.js';

async function run() {
    // Initialize the WASM module.
    // This loads the binary and sets up memory.
    await init();

    // Now you can call Rust functions like normal JS.
    const message = greet("Alice");
    console.log(message);
}

run();

The init function loads the WASM binary and sets up the linear memory. You must call init before invoking any exported functions. The convention is to await init() at the top of your application logic. Never call exported functions before initialization resolves.

Run wasm-bindgen on the release binary. Debug builds are too large for the web.

Realistic example: Classes and state

wasm-bindgen supports exporting Rust structs as JavaScript classes. This lets you maintain state in Rust while exposing an object-oriented interface to JavaScript. Mark the struct and its methods with #[wasm_bindgen].

use wasm_bindgen::prelude::*;

/// A simple counter that lives in WASM memory.
#[wasm_bindgen]
pub struct Counter {
    count: u32,
}

#[wasm_bindgen]
impl Counter {
    /// Creates a new counter starting at zero.
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter {
        Counter { count: 0 }
    }

    /// Increments the counter and returns the new value.
    pub fn increment(&mut self) -> u32 {
        self.count += 1;
        self.count
    }
}

The #[wasm_bindgen(constructor)] attribute marks the factory function. wasm-bindgen generates a JavaScript class with a constructor that calls this function. The &mut self method becomes a JavaScript method that mutates the object. The tool manages the pointer to the Rust struct behind the scenes.

In JavaScript, you use the class like any other object.

import init, { Counter } from './pkg/your_crate.js';

await init();
const counter = new Counter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2

You can also import JavaScript functions into Rust. This lets Rust call back into JavaScript. Use an extern "C" block with the #[wasm_bindgen] attribute.

#[wasm_bindgen]
extern "C" {
    /// Calls the JavaScript `alert` function.
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn trigger_alert() {
    alert("Hello from Rust!");
}

The tool generates the glue to invoke the JavaScript function. Rust can now call alert as if it were a native function. The signature must match the JavaScript function.

Memory, strings, and the boundary

Strings are the biggest source of confusion. When you pass a &str to Rust, wasm-bindgen copies the string into WASM linear memory. When you return a String, it allocates memory in WASM and returns a pointer and length. The JavaScript wrapper handles the cleanup. You don't manage this manually.

If you try to return a &str, the compiler stops you. Lifetimes don't cross the boundary. The Rust string must own its data. The boundary is a wall. Data crosses by copy, not by reference.

For complex JavaScript objects that don't map to Rust types, use JsValue. This is the universal type. It represents any JavaScript value. You lose type safety, but you gain flexibility.

use wasm_bindgen::prelude::*;

/// Logs a JavaScript value to the console.
#[wasm_bindgen]
pub fn log_value(value: JsValue) {
    // JsValue can hold any JS type.
    // You can inspect it or pass it to other JS functions.
    console::log_1(&value);
}

The console module comes from the web-sys or js-sys crates. JsValue is the escape hatch. Use it when you need to pass data that wasm-bindgen doesn't know how to convert. Treat JsValue as opaque. You can't read the contents directly from Rust. You must pass it back to JavaScript or use helper crates to inspect it.

Pitfalls and errors

If you try to return a type that wasm-bindgen doesn't know how to convert, the compiler rejects you with E0277 (trait bound not satisfied). The error message will mention IntoWasmAbi or FromWasmAbi. This means you're trying to pass a Rust type across the boundary that has no JavaScript equivalent. Wrap it in a primitive, or use JsValue.

Calling println! in a WebAssembly module does nothing. The WASM standard has no concept of a console. You need to import console.log from JavaScript. Use the console_error_panic_hook crate to see Rust panics in the browser console. Without it, a panic just freezes the page.

use wasm_bindgen::prelude::*;

/// Initialize panic hook to see errors in console.
#[wasm_bindgen(start)]
pub fn main() {
    // This runs automatically when the module loads.
    console_error_panic_hook::set_once();
}

The #[wasm_bindgen(start)] attribute marks a function to run on initialization. This is the standard place to set up panic hooks. Add the panic hook early. Debugging a silent freeze is painful.

Another common issue is using standard library features that don't exist in WASM. File I/O, threads, and networking are not available in the base WASM environment. You must use JavaScript APIs for these operations. Import the necessary functions from JavaScript or use crates like web-sys that provide bindings to browser APIs.

Decision matrix

Use wasm-bindgen when you need to export Rust functions to JavaScript or import JavaScript functions into Rust with automatic type conversion. Use js-sys when you need to call standard JavaScript built-ins like Math, Date, or JSON from Rust without writing manual bindings. Use web-sys when you need to interact with browser APIs like the DOM, fetch, or localStorage, and you want compile-time verification of the web platform. Use raw WebAssembly without bindings only when you are implementing a custom runtime that manages memory and function tables manually, which is rarely necessary for web applications.

Stick to the ecosystem. Writing raw bindings is a trap.

Where to go next