wasm-bindgen Deep Dive
A raw WebAssembly module produced from Rust can only pass and return numbers. wasm-bindgen is the
tool that closes the gap between that integer-only ABI and the rich types Rust and JavaScript actually
use — strings, structs, arrays, closures, and whole objects. It does this not by extending the Wasm
binary format but by generating a JavaScript shim that performs the encode/decode dance for you, plus a
matching .d.ts so the boundary is typed end to end. This guide reads that generated glue line by line,
explains every attribute you put in your Rust source, and shows where the bytes actually move through
linear memory.
If you treat wasm-bindgen as a black box, you will eventually hit a performance cliff, a memory leak,
or a cryptic recursive use of an object detected panic and have no idea why. If you understand the
shim, all of those become obvious. The goal here is to make the generated code legible.
Prerequisites
- [ ] Rust 1.78 or newer with the
wasm32-unknown-unknowntarget installed (rustup target add wasm32-unknown-unknown) - [ ]
wasm-bindgen-cli0.2.92+ installed and version-matched to thewasm-bindgencrate inCargo.toml - [ ]
wasm-pack0.12+ (it drivescargo buildandwasm-bindgenin one command) - [ ] A browser with WebAssembly support (any current Chrome, Firefox, or Safari) for the ES-module target
- [ ] A static file server that sets the correct
application/wasmMIME type
A note on version matching: the CLI and the crate must agree exactly. A mismatch surfaces as
rust wasm file schema version: 0.2.92 / this binary schema version: 0.2.90 at build time. Pin both.
Where wasm-bindgen sits in the toolchain
wasm-bindgen is a post-processing step. rustc (via cargo build --target wasm32-unknown-unknown)
first emits a raw .wasm that contains placeholder imports and a custom section describing every
bindgen-annotated item. The wasm-bindgen CLI then reads that custom section, rewrites the module, and
emits the JavaScript glue and TypeScript declarations alongside it. wasm-pack simply runs both steps
and writes an npm-ready package. The same pipeline is described from the compilation side in the
Rust to Wasm compilation guide,
and the typed wrapper it produces is what your bundler consumes during
ESM bindings & module generation.
Reading the generated glue line by line
Start with the simplest non-trivial case: a function that takes a &str and returns a String.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
After wasm-pack build --target web, the generated pkg/<crate>.js contains a wrapper named greet.
Stripped of comments, it looks roughly like this:
export function greet(name) {
let deferred2_0, deferred2_1;
try {
const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.greet(ptr0, len0); // raw export: (ptr, len) -> (ret_ptr, ret_len)
deferred2_0 = ret[0];
deferred2_1 = ret[1];
return getStringFromWasm0(ret[0], ret[1]);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
Every interesting thing about the boundary is in those eight lines. Walk them in order:
passStringToWasm0encodes the JavaScript string as UTF-8 directly into the module’slinear memory. It asks the module for space by calling the exported__wbindgen_malloc, gets a pointer, and usesTextEncoder.encodeIntoto write the bytes. It stashes the byte length in the module-levelWASM_VECTOR_LENbecause JavaScript has no way to return two values cleanly here.ptr0/len0are the pointer and length — two plaini32s. This is the only thing the raw export ever sees. The string never crosses the boundary; only its address and size do.wasm.greet(ptr0, len0)is the actual Wasm call. The Rust side reconstructs a&strfrom the pointer and length, runsformat!, allocates the result inlinear memory, and returns a second pointer/length pair describing the output bytes.getStringFromWasm0builds aUint8Arrayview overret[0]..ret[0]+ret[1]and runs it throughTextDecoderto produce the returned JavaScript string.__wbindgen_freein thefinallyblock releases the output allocation. The input allocation is freed inside the Rust function when the reconstructed&str’s backing box is dropped.
The whole mechanism is the manual ABI from the
parent area on the interop boundary made automatic. If you ever
need to verify what is actually happening, you can read these same passStringToWasm0 /
getStringFromWasm0 helpers near the top of the generated .js.
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
Note getUint8ArrayMemory0() — it lazily re-creates the cached Uint8Array whenever
cachedUint8ArrayMemory0.byteLength === 0, which is exactly the detached-buffer case that
memory.grow produces. That single guard is wasm-bindgen’s answer to the most common interop bug.
The #[wasm_bindgen] attribute
The attribute is a procedural macro that does two things at once: it generates an extern-facing
shim in the Wasm module and records a description of the item in a custom section the CLI later reads.
You apply it in four main places.
On free functions (shown above) it exports the function with marshaling. On a struct it makes
the type usable as a JavaScript class. On an impl block it exposes methods and associated
functions as instance and static methods. On an extern "C" block it imports a JavaScript
function into Rust (covered below).
#[wasm_bindgen]
pub struct Counter {
value: i32,
}
#[wasm_bindgen]
impl Counter {
#[wasm_bindgen(constructor)]
pub fn new(start: i32) -> Counter {
Counter { value: start }
}
pub fn increment(&mut self) -> i32 {
self.value += 1;
self.value
}
#[wasm_bindgen(getter)]
pub fn value(&self) -> i32 {
self.value
}
}
Rust structs become JavaScript classes — and why free() exists
The Counter above is generated on the JavaScript side as a real class:
import init, { Counter } from "./pkg/my_crate.js";
await init();
const c = new Counter(10);
console.log(c.increment()); // 11
console.log(c.value); // 11 (getter)
c.free(); // releases the Rust struct in linear memory
A JavaScript Counter object holds nothing but an integer __wbg_ptr — the pointer to the Rust struct
living in linear memory. The struct’s bytes are owned by the module, not by JavaScript, and the
JavaScript garbage collector knows nothing about them. That is why free() exists: calling it runs the
Rust Drop glue and releases the heap allocation. Forget it and you leak the struct for the lifetime of
the page. Newer wasm-bindgen can attach a FinalizationRegistry to call free automatically, but
finalization is non-deterministic, so explicit free() in a finally block remains the disciplined
pattern for anything large or numerous.
Importing JavaScript into Rust
The reverse direction uses extern "C" blocks under the attribute. Each signature becomes a placeholder
import in the raw .wasm; wasm-bindgen wires it to the named JavaScript function in the glue.
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
#[wasm_bindgen(js_name = prompt)]
fn ask(message: &str) -> Option<String>;
}
#[wasm_bindgen]
pub fn run() {
log("called from Rust");
}
This is the same import object machinery the engine binds at instantiation, just declared from Rust.
The whole standard browser surface is pre-declared this way in the web-sys and js-sys crates, which
the companion guide on
calling Web APIs from Rust
puts to work against window, document, and fetch.
JsValue: the heap of JavaScript references
Not every JavaScript value can be serialized into linear memory — a DOM node, a Promise, or an
arbitrary object has no byte layout Rust can read. For those, wasm-bindgen keeps a side table on the
JavaScript side (heap / getObject / addHeapObject in the glue). Rust holds an opaque JsValue,
which is really just an i32 index into that table. When Rust passes a JsValue back, the glue looks
it up; when Rust drops one, __wbindgen_object_drop_ref removes it from the table so the JavaScript GC
can reclaim it.
#[wasm_bindgen]
pub fn describe(value: &JsValue) -> String {
if value.is_string() {
format!("string: {}", value.as_string().unwrap())
} else {
format!("type: {}", value.js_typeof().as_string().unwrap())
}
}
Every web-sys type — Window, Document, Element — is a thin newtype wrapper around JsValue,
which is why DOM manipulation from Rust is, mechanically, just passing these table indices around.
serde-wasm-bindgen for whole objects
When you want a plain JavaScript object turned into a Rust struct (or vice versa) by value rather than
by reference, reach for serde-wasm-bindgen. It serializes across the boundary using Serde’s data model,
copying each field into linear memory as it goes.
use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
pub struct Point { x: f64, y: f64 }
#[wasm_bindgen]
pub fn translate(input: JsValue) -> Result<JsValue, JsValue> {
let mut p: Point = serde_wasm_bindgen::from_value(input)?;
p.x += 1.0;
Ok(serde_wasm_bindgen::to_value(&p)?)
}
This trades a copy for ergonomics: the object is fully reconstructed in Rust memory. The dedicated guide on passing JS objects to Rust covers the failure modes and the copy cost in detail.
Closures and the forget() trap
To hand a Rust callback to JavaScript — say, an event listener or a setTimeout handler — you wrap it
in a Closure. The wrapper allocates a JsValue that, when invoked, trampolines back into Rust.
use wasm_bindgen::prelude::*;
use wasm_bindgen::closure::Closure;
let cb = Closure::<dyn FnMut()>::new(move || log("tick"));
set_interval(&cb); // pass &cb to JS
cb.forget(); // leak it so it outlives this scope
The gotcha is lifetime. If the Closure is dropped while JavaScript still holds a reference, the next
invocation calls freed Rust code and traps with null function or function signature mismatch. The
quick fix, cb.forget(), deliberately leaks the closure so it lives forever — fine for one global
listener, a steady memory leak if you do it per event. The disciplined alternative is to store the
Closure in a struct field whose lifetime matches the listener and drop it on teardown.
Build workflow
A complete build from a clean checkout looks like this.
-
Add the dependency and target the ES-module output. In
Cargo.toml:[lib] crate-type = ["cdylib"] [dependencies] wasm-bindgen = "0.2" serde = { version = "1", features = ["derive"] } serde-wasm-bindgen = "0.6" -
Build with
wasm-pack, which runscargo buildthen thewasm-bindgenCLI:wasm-pack build --target web --out-dir pkg -
Or run the two underlying steps by hand to see the seam:
cargo build --release --target wasm32-unknown-unknown wasm-bindgen target/wasm32-unknown-unknown/release/my_crate.wasm \ --target web --out-dir pkg -
Import the generated module and initialize it before any call:
import init, { greet } from "./pkg/my_crate.js"; await init(); console.log(greet("WebAssembly"));
The await init() is mandatory for the web target — it fetches and instantiates the .wasm, then
binds the import object. Skipping it leaves wasm undefined and every wrapper throws
Cannot read properties of undefined (reading 'greet').
Glue size and tradeoffs
The generated glue is not free. Concrete numbers for the four-line greet crate, release build:
- Raw
rustcoutput before bindgen: ~1.9 MB (debug allocator and panic machinery dominate). - After
wasm-bindgen --target web: a ~17 KB.jsshim plus the processed.wasm. - After
wasm-opt -Ozand--target web: the.wasmdrops to ~14 KB; the.jsglue is unchanged. - Add
serde-wasm-bindgenand one#[derive(Deserialize)]struct: roughly +4–6 KB of.wasmfor the Serde monomorphization, plus a few hundred bytes of glue per exported type.
Two levers matter. Setting opt-level = "z" and lto = true in the release profile, then running
wasm-opt, removes most of the bloat; the size mechanics are in
reducing bundle size with wasm-opt.
The second lever is API shape: each exported string or object adds marshaling glue, so a handful of
coarse functions that batch work produce far less glue (and far fewer boundary crossings) than dozens of
fine-grained getters.
Gotchas
closure invoked recursively or after being dropped — a Closure was dropped while JavaScript still
held it. Either forget() it or store it somewhere with a matching lifetime.
recursive use of an object detected which would lead to unsafe aliasing in rust — you re-entered a
&mut self method while a previous borrow was still live (common when a callback calls back into the same
exported struct). Restructure so the mutable borrow ends before the callback runs.
rust wasm file schema version: 0.2.92 / this binary schema version: 0.2.90 — the wasm-bindgen CLI
and the wasm-bindgen crate versions differ. Reinstall the CLI to match: cargo install -f wasm-bindgen-cli --version 0.2.92.
TypeError: wasm.__wbindgen_malloc is not a function — you imported the processed .wasm directly
instead of the generated .js wrapper, or forgot await init(). Always import the .js.
Verification
Confirm what wasm-bindgen actually emitted rather than trusting the source. List the exports and
imports of the processed module with wasm-objdump:
wasm-objdump -x pkg/my_crate_bg.wasm | grep -A20 "Export\["
You should see __wbindgen_malloc, __wbindgen_free, __wbindgen_realloc, your greet export, and the
exported memory. The presence of those allocator exports confirms the marshaling helpers in the glue
have real targets. To inspect the glue itself, open pkg/my_crate.js and search for the wrapper named
after your function — the passStringToWasm0 / getStringFromWasm0 calls reveal exactly which arguments
get copied into linear memory. For a deeper read of the binary, the techniques in
decoding Wasm opcodes for debugging
apply unchanged to a bindgen-processed module.
In this guide
- Passing JS objects to Rust with wasm-bindgen —
using
JsValueandserde-wasm-bindgento deserialize a whole JavaScript object into a Rust struct. - Calling Web APIs from Rust with wasm-bindgen —
using
web-sysandjs-systo reachwindow,document, the DOM, andfetchfrom Rust.
Frequently Asked Questions
Do I have to use wasm-pack, or can I run wasm-bindgen directly?
Either works. wasm-pack build is cargo build --target wasm32-unknown-unknown followed by the
wasm-bindgen CLI and a little npm packaging. Running the two commands yourself, as shown above, produces
the same pkg/ and is useful when you want to slot wasm-opt or a custom step in between.
Why does my returned JavaScript object need .free() but a returned string does not?
A string is copied out of linear memory into a real JavaScript string during the call, so the module’s
copy is freed immediately in the wrapper’s finally. An exported struct is not copied — the JavaScript
object only holds a pointer into the module, so you must call free() to release the bytes it points at.
Is serde-wasm-bindgen slower than passing primitives? Yes, measurably, because it walks and copies every field. For a small fixed struct called occasionally the cost is negligible; for a hot loop passing large objects, prefer flat primitive arguments or a zero-copy buffer. The tradeoff is quantified in the companion guide on passing JS objects to Rust.
Can I avoid the JavaScript glue entirely? Only by hand-writing the ABI against the raw exports, which means owning all the string/struct marshaling yourself. That is occasionally worth it for a single tiny export, but for anything with structs, closures, or objects, the generated glue is both correct and smaller than what most people write by hand.
Related
- JS/Wasm Interop & Memory Management — the boundary ABI this glue automates.
- Rust to Wasm compilation guide — the toolchain that runs
wasm-bindgen. - ESM bindings & module generation — packaging the generated wrapper for a bundler.
- Passing JS objects to Rust with wasm-bindgen — serde deserialization across the boundary.
- Calling Web APIs from Rust with wasm-bindgen — web-sys and js-sys in practice.
← Back to JS/Wasm Interop & Memory Management