How to Call Rust from JavaScript Using WASM

Web
Compile Rust to WebAssembly using wasm-bindgen and import the generated module into your JavaScript project.

When JavaScript hits the wall

You're building a browser game. The physics simulation is choking on a thousand particles. Your JavaScript loop is the bottleneck. You hear Rust can run in the browser and is blazing fast. You want to offload the math to Rust while keeping your UI in JavaScript. This is exactly what WebAssembly does.

You don't rewrite the app. You carve out the hot path, write it in Rust, compile it to a binary the browser understands, and call it from your existing JavaScript. The result is near-native performance for the heavy lifting, with zero disruption to your frontend workflow.

The bridge between worlds

WebAssembly is a low-level binary format that runs in the browser alongside JavaScript. It is not a new language. It is a compilation target. You write Rust, compile it to a .wasm file, and the browser executes it. The speed comes from the binary format and the sandboxed execution model. The browser can optimize WASM aggressively because the structure is rigid and predictable.

The tricky part is the boundary. JavaScript expects strings, objects, and dynamic types. Rust expects integers, pointers, and strict lifetimes. They speak different languages. wasm-bindgen is the translator. It generates the JavaScript glue code that packs arguments, invokes the WASM function, reads the result, and unpacks it. You write Rust code with a macro, and wasm-bindgen handles the rest.

The bridge is invisible to the user but essential for the developer. Trust the glue code.

Minimal setup

You need three things: the wasm-bindgen dependency, the cdylib crate type, and the wasm32-unknown-unknown target.

# Cargo.toml
[dependencies]
wasm-bindgen = "0.2" // The bridge between Rust and JS

[lib]
crate-type = ["cdylib"] // Required for WASM output

The cdylib type tells cargo to produce a dynamic library. WASM modules are essentially dynamic libraries for the web. Without this, cargo builds a static library that the browser cannot load.

// src/lib.rs
use wasm_bindgen::prelude::*;

/// Add two integers and return the sum.
#[wasm_bindgen]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

The #[wasm_bindgen] macro marks the function for export. It does not generate code at compile time. It emits metadata that the wasm-bindgen CLI tool reads later. Any function you want to call from JavaScript must have this macro. Private functions or functions without the macro are invisible to the outside world.

Build the project with the WASM target.

rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release

The --release flag is mandatory. Debug builds are slow and produce massive binaries. The browser has limited memory. A debug build can bloat the WASM file by ten times, causing load failures on mobile devices.

Run wasm-bindgen on the output.

wasm-bindgen target/wasm32-unknown-unknown/release/your_crate.wasm --out-dir pkg

This command produces two files in pkg: a .wasm binary and a .js companion file. The .js file contains the init function and the exported functions. The .wasm file contains the compiled code.

Import and use it in JavaScript.

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

// Initialize the WASM module.
init().then(() => {
    console.log(add(1, 2)); // Outputs: 3
});

The init function fetches and instantiates the WASM module. You must call it before using any exported functions. The add function is a JavaScript wrapper generated by wasm-bindgen. It handles the argument packing and result unpacking automatically.

How the glue works

When you call add(1, 2) in JavaScript, the glue code does three things. It allocates space for the arguments in the WASM linear memory. It writes the values 1 and 2 into that space. It calls the exported WASM function with the pointer to the arguments. The Rust function runs, computes the sum, writes the result back to memory, and returns. The glue code reads the result, frees the temporary memory, and returns the value to JavaScript.

This process is fast for small arguments. The overhead is negligible for a function called once per frame. For a function called inside a tight loop, the overhead dominates. The boundary crossing costs cycles. Batch your calls. Pass a single large buffer instead of many small values.

The community calls this "batching". Minimize the number of calls across the boundary. Aggregate data in JS, call Rust once, aggregate results back.

Realistic example: strings and memory

Numbers are easy. Strings require memory management. When you pass a string from JavaScript to Rust, wasm-bindgen allocates memory in the WASM heap, copies the data, and passes a pointer and length. The Rust side sees a &str. When you return a String, the reverse happens. This allocation has a cost. For hot loops, avoid passing strings back and forth. Use typed arrays or buffers instead.

// src/lib.rs
use wasm_bindgen::prelude::*;

/// Reverse a string.
#[wasm_bindgen]
pub fn reverse_string(input: &str) -> String {
    input.chars().rev().collect()
}

/// Process a buffer without copying.
#[wasm_bindgen]
pub fn process_buffer(data: &[u8]) -> i32 {
    // Sum the bytes.
    data.iter().sum()
}

The reverse_string function works, but it allocates. The process_buffer function takes a slice. wasm-bindgen supports &[u8] and Vec<u8>. When you pass a Uint8Array from JavaScript, wasm-bindgen can share the memory without copying. This is much faster for large data.

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

init().then(() => {
    const buffer = new Uint8Array([1, 2, 3, 4]);
    console.log(process_buffer(buffer)); // Outputs: 10
});

The convention is to use &[u8] for read-only buffers and Vec<u8> for mutable buffers. wasm-bindgen handles the conversion automatically. If you need to modify the buffer in Rust and see the changes in JavaScript, return a Vec<u8> or use js_sys::Uint8Array directly.

Pitfalls and errors

Panic handling is the first trap. If your Rust code panics, the WASM module aborts. The browser shows a generic error with no stack trace. You need console_error_panic_hook to see what went wrong. Add it to your initialization code.

// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen(start)]
pub fn main() {
    // Set up panic hook to print errors to console.
    console_error_panic_hook::set_once();
}

The #[wasm_bindgen(start)] attribute marks a function to run when the module is instantiated. It runs before any other exports are called. This is the right place to initialize panic hooks and other global state.

Add the panic hook. Debugging WASM without it is impossible.

Another trap is the target triple. You must build for wasm32-unknown-unknown. Building for x86_64 produces a binary the browser cannot run. The compiler will not stop you from building the wrong target, but the browser will reject it immediately. If you forget to add the target, cargo fails with a "target not found" error. Run rustup target add wasm32-unknown-unknown once.

If you try to export a type that wasm-bindgen does not support, the build fails with E0277 (trait bound not satisfied). The error mentions wasm_bindgen::describe::WasmDescribe. Stick to primitives, String, Vec, and structs marked with #[wasm_bindgen]. Custom types must implement the WasmDescribe trait, which wasm-bindgen generates automatically for structs with the macro.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub struct Point {
    x: f64,
    y: f64,
}

#[wasm_bindgen]
impl Point {
    #[wasm_bindgen(constructor)]
    pub fn new(x: f64, y: f64) -> Point {
        Point { x, y }
    }

    pub fn distance(&self, other: &Point) -> f64 {
        let dx = self.x - other.x;
        let dy = self.y - other.y;
        (dx * dx + dy * dy).sqrt()
    }
}

The #[wasm_bindgen(constructor)] macro marks a function as the constructor. JavaScript calls it with new Point(1.0, 2.0). Methods on the struct are exported as instance methods. The &self and &Point arguments work automatically. wasm-bindgen handles the pointer passing.

Async and promises

JavaScript is asynchronous. Rust functions are synchronous by default. wasm-bindgen bridges this gap. If you mark a Rust function as async, it returns a JavaScript Promise. The Rust code runs on the event loop. This is crucial for I/O. You cannot block the main thread in the browser. Use async for any operation that waits for network or disk.

use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
use web_sys::Request;

/// Fetch data from a URL.
#[wasm_bindgen]
pub async fn fetch_data(url: &str) -> Result<String, JsValue> {
    // Create a request.
    let request = Request::new_with_str(url)?;
    
    // Get the global window and fetch.
    let window = web_sys::window().unwrap();
    let resp_value = JsFuture::from(window.fetch_with_request(&request)).await?;
    
    // Parse the response.
    let resp: web_sys::Response = resp_value.dyn_into()?;
    let text = JsFuture::from(resp.text()?).await?;
    
    Ok(text.as_string().unwrap())
}

The async function returns a Promise to JavaScript. You call it with await fetchData(url). The wasm_bindgen_futures crate provides JsFuture to bridge Rust futures and JS promises. The web_sys crate provides safe bindings for browser APIs.

The convention is to use gloo for async tasks. gloo wraps wasm-bindgen-futures and web-sys with a nicer API. It handles timers, fetch, and storage with less boilerplate.

Decision matrix

Use wasm-bindgen when you need to expose Rust functions to JavaScript. It handles the type translation and memory management automatically.

Use web-sys when you need to access browser APIs like DOM manipulation or Canvas from Rust. It provides safe Rust bindings for the entire Web API.

Use js-sys when you need to call JavaScript built-ins like Math.random or JSON.stringify from Rust. It wraps the global JS objects.

Use wasm-pack when you want to publish your WASM library to npm. It bundles the build, testing, and packaging steps into a single command.

Reach for wasm-bindgen-test when writing unit tests that run in a headless browser environment. It lets you test the JS interop directly.

Pick the tool that matches your boundary. wasm-bindgen for exports, web-sys for imports.

Where to go next