wasm-bindgen: The Rust–JS Bridge

This chapter introduces wasm-bindgen, the library that makes Rust–JS interop practical: structs, strings, closures, Promises.

What wasm-bindgen Does

Chapter 5 showed the cost of raw interop: every string crossing the boundary is a handful of alloc/encode/pass/decode/free steps. For a few functions, manageable. For a real library, untenable.

wasm-bindgen automates the dance. You write:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

It generates:

  • Rust shims that handle string encoding and alloc/dealloc.
  • A JS module with a matching greet(name: string): string function.
  • All the glue in between.

You use it as if strings just work.

Setup

Add to Cargo.toml:

[dependencies]
wasm-bindgen = "0.2"

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

Write the Rust:

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn greet(name: &str) -> String {
    format!("Hello, {name}!")
}

Build:

cargo build --target wasm32-unknown-unknown --release
wasm-bindgen \
    target/wasm32-unknown-unknown/release/my_crate.wasm \
    --out-dir pkg \
    --target web

The --target web option generates a .js loader plus the .wasm for direct browser <script type="module"> use. Other targets: bundler (webpack/Vite/etc.), nodejs (Node), no-modules (global script tag).

The output in pkg/:

my_crate.js           JS shim
my_crate_bg.wasm      The Wasm module
my_crate.d.ts         TypeScript definitions
my_crate_bg.wasm.d.ts

Use from the browser:

<script type="module">
  import init, { greet } from "./pkg/my_crate.js";

  await init();
  console.log(greet("Ada"));    // "Hello, Ada!"
</script>

init() fetches and instantiates the Wasm. After that, greet is a plain JS function that takes a string and returns a string. No manual boundary work.

What #[wasm_bindgen] Supports

A lot.

Strings

#[wasm_bindgen]
pub fn upper(s: &str) -> String { s.to_uppercase() }

Numbers

#[wasm_bindgen]
pub fn hypot(a: f64, b: f64) -> f64 { (a * a + b * b).sqrt() }

Booleans

#[wasm_bindgen]
pub fn is_even(n: i32) -> bool { n % 2 == 0 }

Vec and Slices

#[wasm_bindgen]
pub fn sum(nums: &[i32]) -> i32 { nums.iter().sum() }

#[wasm_bindgen]
pub fn double(nums: &[i32]) -> Vec<i32> {
    nums.iter().map(|n| n * 2).collect()
}

From JS, nums is a typed array (Int32Array); the return is Int32Array as well.

Structs

#[wasm_bindgen]
pub struct Counter {
    count: i32,
}

#[wasm_bindgen]
impl Counter {
    #[wasm_bindgen(constructor)]
    pub fn new() -> Counter {
        Counter { count: 0 }
    }

    pub fn increment(&mut self) -> i32 {
        self.count += 1;
        self.count
    }

    pub fn value(&self) -> i32 { self.count }
}

From JS:

import init, { Counter } from "./pkg/my_crate.js";
await init();

const c = new Counter();
console.log(c.increment());    // 1
console.log(c.increment());    // 2
console.log(c.value());        // 2
c.free();                      // manually drop (Rust-side object)

The struct lives in Wasm memory. JS holds a handle; free() tells Rust to drop it.

Enums

#[wasm_bindgen]
pub enum Color { Red, Green, Blue }

#[wasm_bindgen]
pub fn describe(c: Color) -> String {
    match c {
        Color::Red => "red".to_string(),
        Color::Green => "green".to_string(),
        Color::Blue => "blue".to_string(),
    }
}

From JS, enum variants are numeric constants (Color.Red === 0) or a convention-friendly object.

JsValue: Opaque JS Types

When a parameter is "whatever JS you want", use JsValue:

#[wasm_bindgen]
pub fn log_any(value: JsValue) {
    web_sys::console::log_1(&value);
}

JsValue is a handle to a JS object; Rust can't inspect it directly but can pass it around, call methods on it, and so on.

For structured JS values, serde-wasm-bindgen serializes JS objects into Rust types and vice versa:

use serde::{Deserialize, Serialize};
use serde_wasm_bindgen::{from_value, to_value};

#[derive(Serialize, Deserialize)]
pub struct User {
    name: String,
    age: u32,
}

#[wasm_bindgen]
pub fn process_user(js_user: JsValue) -> Result<JsValue, JsValue> {
    let user: User = from_value(js_user)?;
    // ... do something ...
    Ok(to_value(&user)?)
}

Great for JSON-ish payloads.

Web APIs: web-sys and js-sys

The web-sys crate exposes virtually every browser API to Rust. js-sys covers the JS standard library (Array, Object, Promise, etc.).

[dependencies]
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["console", "Window", "Document", "HtmlElement"] }
js-sys = "0.3"
use wasm_bindgen::prelude::*;
use web_sys::window;

#[wasm_bindgen]
pub fn set_title(title: &str) -> Result<(), JsValue> {
    let doc = window()
        .and_then(|w| w.document())
        .ok_or_else(|| JsValue::from_str("no document"))?;
    doc.set_title(title);
    Ok(())
}

Each browser API is a feature flag; enable only what you use to keep bundle sizes down.

Closures (Callbacks)

Passing a Rust closure to JS is possible but awkward due to lifetime management.

use wasm_bindgen::prelude::*;
use wasm_bindgen::closure::Closure;
use web_sys::window;

#[wasm_bindgen]
pub fn delayed_hello() {
    let cb = Closure::wrap(Box::new(|| {
        web_sys::console::log_1(&"delayed hello".into());
    }) as Box<dyn Fn()>);

    window()
        .unwrap()
        .set_timeout_with_callback_and_timeout_and_arguments_0(
            cb.as_ref().unchecked_ref(),
            1000,
        )
        .unwrap();

    cb.forget();    // leak the closure; it outlives this call
}

cb.forget() leaks the closure into JS memory. For one-shot callbacks, fine; for long-lived ones, store the Closure somewhere you control.

Async and Promises

wasm-bindgen supports async functions. Rust async fn becomes a JS function returning a Promise.

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

#[wasm_bindgen]
pub async fn fetch_url(url: &str) -> Result<JsValue, JsValue> {
    let resp = JsFuture::from(window().unwrap().fetch_with_str(url)).await?;
    let resp: web_sys::Response = resp.dyn_into()?;
    let text = JsFuture::from(resp.text()?).await?;
    Ok(text)
}

From JS:

const body = await fetch_url("https://example.com");
console.log(body);

Very ergonomic once set up.

The Price

wasm-bindgen is not free.

  • Binary size. The generated glue adds 50 to 200 KB depending on features. wasm-opt -Oz trims some. If you're doing two numeric functions, bare Wasm is lighter.
  • Start-up time. The JS shim is parsed and executed before your first call.
  • Complexity. When things go wrong, the error is in generated code, not yours. Debuggers improve each year but it's still a layer.

For most browser Wasm work, the trade-offs are worth it. Where they aren't: pure computation modules with simple interfaces, ship bare.

Common Pitfalls

Forgetting await init(). Calling greet before init resolves blows up with "WebAssembly module not instantiated".

Shadowing JS names. Exporting a Rust function called Array from Wasm collides with JS's Array. Rename in Rust.

Leaked closures. Closure::forget() is a deliberate memory leak. Use sparingly.

Calling .free() twice on a struct. Double-free. Track handles carefully or use finalization registries in modern browsers.

Passing large typed arrays and expecting zero-copy. wasm-bindgen often copies at the boundary. For truly large shared buffers, design around the memory you export.

Next Steps

Continue to 07-wasm-pack-and-bundlers.md to ship a module to npm.