Reference Types and externref
This guide answers one task: let a WebAssembly module hold and pass around references to host objects directly, instead of maintaining a JavaScript-side table of integers — and understand what the engine does and does not manage for you.
Prerequisites
- [ ] An engine with reference types, which is every current one.
- [ ] A toolchain that emits them: LLVM 18+, or
wasm-bindgen0.2.90+ which uses them automatically. - [ ] A case where the module needs to hold a host object across calls.
- [ ] Familiarity with the older integer-handle pattern, which this replaces.
The problem it solves
WebAssembly 1.0 had four value types, all numbers. A module could not hold a reference to a JavaScript object, so every binding layer invented the same workaround: keep a JavaScript array of objects, hand the module an index, and translate on every call.
// the pre-reference-types pattern, still visible in older glue
const heap = [];
function store(obj) { heap.push(obj); return heap.length - 1; }
function get(idx) { return heap[idx]; }
instance.exports.set_callback(store(myFunction)); // module holds an integer
That works and costs a lookup per access, an array that grows forever unless explicitly freed, and a whole class of bug where an index outlives its object or is reused after being freed.
Reference types add two value types the module can hold directly: externref for an opaque host value,
and funcref for a function reference. The engine tracks them, so the side table disappears.
Using externref directly
A module can take an externref as a parameter, return one, store it in a local or a global, and put it
in a table. What it cannot do is inspect it: there are no instructions to read a field, call a method or
compare two references beyond a null check.
(module
(global $callback (mut externref) (ref.null extern))
(func (export "set_callback") (param $cb externref)
(global.set $callback (local.get $cb)))
(func (export "has_callback") (result i32)
(ref.is_null (global.get $callback))
(i32.eqz))
(import "host" "invoke" (func $invoke (param externref) (param i32)))
(func (export "notify") (param $value i32)
(call $invoke (global.get $callback) (local.get $value))))
The module stores the reference and hands it back to the host when it wants something done with it. That is the whole model: the module is a custodian, not an interpreter, of host values.
Tables of references
A table can hold externref or funcref elements, which gives the module a growable collection of host
references without any JavaScript bookkeeping.
(table $objects 0 externref)
(func (export "add") (param $obj externref) (result i32)
(local $idx i32)
(local.set $idx (table.size $objects))
(drop (table.grow $objects (local.get $obj) (i32.const 1)))
(local.get $idx))
(func (export "get") (param $idx i32) (result externref)
(table.get $objects (local.get $idx)))
That is the side table, moved inside the module and managed by the engine. Entries can be overwritten with
table.set, and setting a slot to ref.null extern releases the reference so the object can be
collected — which is the manual step that remains.
JavaScript can also reach a table directly, which is occasionally useful for debugging:
const t = instance.exports.objects; // a WebAssembly.Table
console.log(t.length, t.get(0));
What wasm-bindgen does
Most Rust developers never write externref by hand, because wasm-bindgen uses it automatically for
JsValue and everything built on it.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Recorder { sink: js_sys::Function }
#[wasm_bindgen]
impl Recorder {
#[wasm_bindgen(constructor)]
pub fn new(sink: js_sys::Function) -> Recorder { Recorder { sink } }
pub fn emit(&self, value: f64) -> Result<(), JsValue> {
self.sink.call1(&JsValue::NULL, &JsValue::from_f64(value))?;
Ok(())
}
}
The js_sys::Function field is an externref held in the module. With reference types the generated glue
has no heap array and no index arithmetic, which is both faster and less code — one of the quieter reasons
modern wasm-bindgen output is smaller than it used to be.
Lifetime is still yours to manage in the sense that Rust’s ownership applies: dropping the Recorder drops
the reference and lets the object be collected. What has gone is the manual free of an index slot.
Lifetimes, which did not go away
Reference types remove the index bookkeeping and not the lifetime question. A reference the module holds keeps the object alive, and holding one longer than intended is a leak with a new shape.
Three places hold references, and each needs an answer for when it releases.
A global holds one reference for the life of the instance unless something overwrites it. A callback stored in a global and never replaced keeps its closure — and everything the closure captures — alive until the instance is discarded.
A table holds one per slot. Removing an entry means writing ref.null extern into the slot; shrinking
is not possible, so a table used as a registry grows monotonically unless slots are reused.
A local releases at the end of the function, which is the easy case and the reason most code never has to think about this.
(func (export "clear_slot") (param $idx i32)
(table.set $objects (local.get $idx) (ref.null extern)))
On the Rust side, ownership handles this: a JsValue dropped is a reference released. The failure mode
there is the usual one — a value stored in a long-lived structure that nobody remembers to clear — and it
is diagnosed the same way, by taking a heap snapshot and looking for objects retained by the module.
The practical advice is to treat a stored reference as a resource with an owner, exactly as you would a file handle. A registry that hands out slots should hand out a way to release them, and the interface should make releasing the obvious thing to do rather than an optional courtesy.
Expected output
A module using reference types declares them in its signatures, which wasm-objdump shows:
wasm-objdump -x dist/engine.wasm | grep -m3 'externref\|funcref'
# - type[3] (externref) -> nil
# - table[0] type=externref initial=0
# - global[1] externref mutable=1
# and behaviourally
const r = new Recorder((v) => console.log('got', v));
r.emit(1.5); // got 1.5
r.free(); // drops the reference; the closure can be collected
A module built before reference types shows a __wbindgen_object_drop_ref import and a JavaScript heap
array in its glue — a quick way to tell which generation of binding you are looking at.
Gotchas
- Expecting the module to inspect the value. It cannot; only the host can.
- Leaking references in a table. A slot holding a reference keeps the object alive; null it out.
- Storing an
externrefin linear memory. Not possible — references are not bytes and have no address. - Assuming a null check is a validity check. A non-null reference can still refer to something the host has invalidated in its own terms.
- Mixing generations of glue. Old and new
wasm-bindgenoutput use different models; regenerate rather than mixing. - Comparing references for equality inside the module. There is no such instruction; do it on the host.
Performance note
Replacing the integer-handle pattern with externref removed roughly 2 kB of generated glue for a
medium-sized binding surface and cut the per-call overhead of passing a host object from about 40
nanoseconds to under 10 — the difference between an array lookup with bounds checking and passing a value
the engine already has. For a binding called thousands of times per frame that is measurable; for one
called on a click it is not, and the real benefit there is the absence of a table to leak.
Frequently Asked Questions
Can a table of references be shared between instances?
A WebAssembly.Table can be imported by more than one instance, which makes it a way to share a registry
of host objects between modules — occasionally useful, and a lifetime question to answer deliberately.
Do I need to do anything to use this?
If you use current wasm-bindgen, no — it is already using reference types. If you write bindings by
hand, adopting externref removes your side table.
Can a module hold a reference across an await? Yes: a reference in a global or a table persists across calls, which is what makes callbacks and stored handles work at all.
Does this work in a WASI module too? Yes — reference types are part of the core language rather than a browser feature, so a standalone runtime supports them and a host written in Rust or Go can pass its own opaque values into a module the same way. The pattern is identical; only the host differs.
What is funcref for?
Function references, which are what a table of indirect call targets holds. That is the mechanism behind
function pointers — see
calling function pointers with call_indirect.
Related
- Passing JS objects to Rust with wasm-bindgen — the Rust-level view of this.
- Tables & Dynamic Linking — what tables of references enable.
- Using Wasm GC for managed languages — the proposal that builds on this one.
This is the proposal most people use without knowing it, which is the best possible outcome for a language feature.
← Back to Post-MVP Wasm Proposals in Practice