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 its pkg/ output at hand.
  • [ ] A non-minified build (--dev or 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.

Sections of a wasm-bindgen glue file The glue file starts with module state such as cached memory views and the heap slab, followed by conversion helpers for strings and arrays, wrapper functions for each exported Rust function, classes for exported structs, the import object for functions Rust calls, and the init functions that instantiate the module. module state wasm exports, cachedUint8ArrayMemory0, heap slab conversion helpers passStringToWasm0, getStringFromWasm0, getArrayU8FromWasm0 export wrappers one JS function per #[wasm_bindgen] fn classes one per exported struct: __wbg_ptr, free() imports + init __wbg_get_imports, init / initSync

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.

What one call to an exported function does The wrapper encodes the string argument into Wasm memory with malloc, calls the export with pointer and length, decodes the returned string from memory, frees the returned buffer, and returns a JavaScript string. greet("Ada") JS wrapper encode argument malloc + encode wasm.greet(ptr, len) Rust runs decode result TextDecoder free result release buffer

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 &str is an allocation and a copy. Check the wrapper.
  • Holding a class after free(). __wbg_ptr is 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.

Where the time goes in one greet() call Nanoseconds spent in each part of a call to an exported function taking and returning a short string: encoding the argument, the Rust function body, decoding the result and freeing it. ns per call encode argument 80 ns Rust body 50 ns decode result 70 ns free result 25 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.

← Back to wasm-bindgen Deep Dive