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): stringfunction. - 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 -Oztrims 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.