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-unknown target installed (rustup target add wasm32-unknown-unknown)
  • [ ] wasm-bindgen-cli 0.2.92+ installed and version-matched to the wasm-bindgen crate in Cargo.toml
  • [ ] wasm-pack 0.12+ (it drives cargo build and wasm-bindgen in 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/wasm MIME 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.

wasm-bindgen build and glue flow Rust source annotated with the wasm_bindgen attribute is compiled by rustc to a raw wasm module carrying a custom section. The wasm-bindgen CLI rewrites that module and emits the processed wasm, a JavaScript glue shim, and a TypeScript declaration file that the application imports. Rust source #[wasm_bindgen] rustc raw .wasm + custom section wasm-bindgen CLI rewrite .js glue processed .wasm .d.ts types

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:

  1. passStringToWasm0 encodes the JavaScript string as UTF-8 directly into the module’s linear memory. It asks the module for space by calling the exported __wbindgen_malloc, gets a pointer, and uses TextEncoder.encodeInto to write the bytes. It stashes the byte length in the module-level WASM_VECTOR_LEN because JavaScript has no way to return two values cleanly here.
  2. ptr0 / len0 are the pointer and length — two plain i32s. This is the only thing the raw export ever sees. The string never crosses the boundary; only its address and size do.
  3. wasm.greet(ptr0, len0) is the actual Wasm call. The Rust side reconstructs a &str from the pointer and length, runs format!, allocates the result in linear memory, and returns a second pointer/length pair describing the output bytes.
  4. getStringFromWasm0 builds a Uint8Array view over ret[0]..ret[0]+ret[1] and runs it through TextDecoder to produce the returned JavaScript string.
  5. __wbindgen_free in the finally block 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.

  1. 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"
  2. Build with wasm-pack, which runs cargo build then the wasm-bindgen CLI:

    wasm-pack build --target web --out-dir pkg
  3. 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
  4. 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 rustc output before bindgen: ~1.9 MB (debug allocator and panic machinery dominate).
  • After wasm-bindgen --target web: a ~17 KB .js shim plus the processed .wasm.
  • After wasm-opt -Oz and --target web: the .wasm drops to ~14 KB; the .js glue is unchanged.
  • Add serde-wasm-bindgen and one #[derive(Deserialize)] struct: roughly +4–6 KB of .wasm for 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


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.


← Back to JS/Wasm Interop & Memory Management