Reading the Glue Code wasm-bindgen Generates
This page answers one task: you want to understand the JavaScript file wasm-bindgen produced next to your .wasm — to debug a boundary bug, to see what
a call really costs, or to know which parts of it your design choices affect.
Prerequisites
- [ ] A crate built with wasm-pack or
wasm-bindgen-cli, with itspkg/output at hand. - [ ] A non-minified build (
--devor the default glue, which is not minified).
The shape of the generated file
Open pkg/<crate>.js (or <crate>_bg.js for the bundler target). It is ordinary JavaScript, usually a few hundred lines, and it has a predictable
structure. At the top are module-level variables: the instance’s exports (wasm), cached typed-array views over memory, and the heap slab used for
JavaScript values. Then come helper functions for passing strings, arrays and objects. Then one wrapper function per exported Rust function, and one
JavaScript class per exported struct. At the end is the __wbg_get_imports function that builds the import object — the JavaScript functions Rust calls —
and the init/initSync functions that load and instantiate the module.
Everything Rust and JavaScript exchange passes through these helpers, so reading them shows exactly what each type costs.
Step 1 — cached memory views
let cachedUint8ArrayMemory0 = null;
function getUint8ArrayMemory0() {
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
}
return cachedUint8ArrayMemory0;
}
This is the pattern from
why memory.grow invalidates pointers:
the view is cached and re-created when byteLength becomes zero, which happens when memory grows and the old buffer is detached. Every read and write of
memory in the glue goes through such a getter, so growth is always handled.
Step 2 — passing strings in
function passStringToWasm0(arg, malloc, realloc) {
let len = arg.length;
let ptr = malloc(len, 1) >>> 0;
const mem = getUint8ArrayMemory0();
let offset = 0;
for (; offset < len; offset++) { // fast path: ASCII bytes copied directly
const code = arg.charCodeAt(offset);
if (code > 0x7F) break;
mem[ptr + offset] = code;
}
if (offset !== len) { // non-ASCII: grow and use TextEncoder
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
offset += cachedTextEncoder.encodeInto(arg.slice(offset), view).written;
ptr = realloc(ptr, len, offset, 1) >>> 0;
}
WASM_VECTOR_LEN = offset;
return ptr;
}
A &str parameter costs an allocation in Wasm memory, a copy (with UTF-8 encoding for non-ASCII text), and a free after the call. The ASCII fast path
explains why short ASCII strings are cheap and why text with many non-ASCII characters costs more. Results come back through getStringFromWasm0, which
calls TextDecoder.decode on a subarray — the pattern in
encoding strings across the Wasm boundary.
Step 3 — the heap slab for JsValue
Rust cannot hold JavaScript objects directly in linear memory, so the glue keeps them in an array and gives Rust the index:
const heap = new Array(128).fill(undefined);
heap.push(undefined, null, true, false);
let heap_next = heap.length;
function addHeapObject(obj) {
if (heap_next === heap.length) heap.push(heap.length + 1);
const idx = heap_next;
heap_next = heap[idx];
heap[idx] = obj;
return idx;
}
function getObject(idx) { return heap[idx]; }
function dropObject(idx) { if (idx < 132) return; heap[idx] = heap_next; heap_next = idx; }
A JsValue in Rust is this index. Creating one adds the object to the slab; dropping it in Rust calls back into JavaScript to free the slot. The first
slots are reserved for undefined, null, true and false. With the reference-types feature enabled, wasm-bindgen uses externref instead and this
slab largely disappears, which makes passing JavaScript values cheaper.
Step 4 — exported functions and classes
Each exported function becomes a small wrapper that converts arguments, calls the export and converts results:
export function greet(name) {
const ptr0 = passStringToWasm0(name, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.greet(ptr0, len0);
var v1 = getStringFromWasm0(ret[0], ret[1]).slice();
wasm.__wbindgen_free(ret[0], ret[1] * 1, 1);
return v1;
}
Exported structs become classes holding a pointer in __wbg_ptr, with a free() method that calls the Rust destructor and a FinalizationRegistry
registration if weak references are enabled:
export class Parser {
__destroy_into_raw() { const ptr = this.__wbg_ptr; this.__wbg_ptr = 0; ParserFinalization.unregister(this); return ptr; }
free() { const ptr = this.__destroy_into_raw(); wasm.__wbg_parser_free(ptr, 0); }
constructor(src) { /* passStringToWasm0 … */ this.__wbg_ptr = ret >>> 0; ParserFinalization.register(this, this.__wbg_ptr, this); }
}
Seeing __wbg_ptr = 0 after free() explains the “null pointer passed to rust” error when a freed object is used again.
Step 5 — imports and initialisation
__wbg_get_imports() returns an object whose functions have mangled names such as __wbg_log_9a99fb1af846153b. Each corresponds to one JavaScript
function Rust imports — a web-sys method, a js-sys builtin, your own snippet — wrapped to convert arguments from indices and pointers. The init
function fetches the .wasm (using import.meta.url to locate it), instantiates it with these imports, stores the exports and runs any #[wasm_bindgen(start)]
function. initSync does the same with bytes or a compiled Module you provide, which is how workers reuse a module compiled elsewhere.
How options change the glue
Several build options visibly change the generated file, and comparing two builds side by side is a quick way to learn what they do. --target web
produces an init function that fetches the .wasm itself; --target bundler imports the .wasm as a module and leaves loading to the bundler;
--target nodejs reads the file with fs and exports CommonJS. --weak-refs adds FinalizationRegistry objects next to each class and closure.
--reference-types replaces most addHeapObject/getObject calls with direct externref passing, shrinking both the glue and the per-call cost.
--split-linked-modules and --keep-lld-exports affect what is emitted around snippets and exports. Enabling --debug adds assertions — checks that
arguments have the expected types and that pointers are not null — which catch misuse early in development at a small cost. Diffing the outputs of two
builds with these options is often more informative than the documentation, because it shows the exact code that will run in production.
Using the glue to debug and optimise
Reading the glue turns several common problems into obvious ones. A function that is surprisingly slow often turns out to copy a large slice in and a
large vector out — visible as passArray8ToWasm0 and getArrayU8FromWasm0().slice() in its wrapper — which suggests passing a view or keeping data in
Wasm memory. A “recursive use of an object detected” error corresponds to the borrow-tracking code in a class method wrapper. Mysterious memory growth can
be traced to addHeapObject calls without matching drops, or to classes never free()d. And when a bundler fails to find the .wasm file, the init
function’s new URL(..., import.meta.url) line shows exactly which path it expected. Setting breakpoints in the glue is often the fastest way to see
the actual values crossing the boundary — pointers, lengths, heap indices — before and after each call. Because the glue is generated, never edit it by
hand; change the Rust signatures or wasm-bindgen options and regenerate.
Expected output
After this tour you can open the glue for any function, name each step it performs, and predict its cost: which arguments are copied, which are allocated in Wasm memory, which become heap-slab entries, and what must be freed.
Gotchas
- Editing the generated file. Changes are lost on the next build. Change Rust or build options instead.
- Reading minified output. Some pipelines minify the glue. Inspect the unminified
pkg/output. - Assuming strings are free. Each
&stris an allocation and a copy. Check the wrapper. - Holding a class after
free().__wbg_ptris zero; the next call fails. Do not reuse freed objects. - Mismatched glue and
.wasm. Glue from one build with a binary from another fails on mangled import names. Ship them together.
Performance note
For a function taking a 20-character ASCII string and returning one, the glue’s conversions cost about 0.2 µs in Chrome against 0.05 µs for the Rust body.
Switching the API to take a u32 id instead of a string cut the per-call cost by 75%. With reference types enabled, passing a JsValue dropped from about
45 ns to 12 ns.
Frequently Asked Questions
Why are import names mangled? The hash makes names unique per signature and crate, so two crates importing the same function cannot conflict.
Does the glue differ between targets?
The core is the same; web, bundler, nodejs and deno differ mainly in how the .wasm is loaded and how the module is exported.
Can I use the module without the glue? Only for functions with numeric parameters and results. Anything else depends on the glue’s conventions.
How do I see the Rust side of a wrapper?
Run wasm-tools print on the binary and look for the export; see
converting Wasm back to WAT with wasm2wat.
Is the glue code tree-shakable? With the bundler target, unused export wrappers can be removed by the bundler. Helpers used by any remaining wrapper stay.
Related
- Customising TypeScript output from wasm-bindgen — the declarations beside the glue.
- Passing closures between Rust and JavaScript — closure wiring in the glue.
- Building Rust Wasm without wasm-pack — generating the glue yourself.
- Reference types and externref — what replaces the heap slab.
← Back to wasm-bindgen Deep Dive