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-bindgen 0.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.

A side table, or a reference Before reference types, a module held an integer index into a JavaScript array that the glue maintained. With them, the module holds the reference itself and the engine tracks it, removing the table and its lifetime bugs. integer handle module holds 7 heap[7] in JavaScript the object a lookup, and a table to free externref module holds the reference the object no table, no index, no lookup

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.

Ownership on one side, tracing on the other A Rust struct owns a reference to a JavaScript function. Rust's ownership decides when the reference is dropped, and the engine's collector decides when the object is freed once nothing references it. Recorder (Rust) owns a js_sys::Function drop releases it externref the JavaScript function an ordinary object collected when unreferenced engine collector traces through the module A reference held in a module global or table keeps the object alive, which is the lifetime rule that replaces the old manual free.

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.

A handle the module cannot inspect An externref is a reference the module can store and hand back, but never read. The alternative is a side table of integers the host maintains, which the module can corrupt. JS object passed in externref opaque to the module stored in a table held across calls handed back the same object Opaque is the point: the module cannot forge one, so a handle is always a real object the host gave it. The engine's collector sees the reference, so an object held in a table stays alive without a side map. Clearing the table slot is what releases the object — a stale slot is a leak the host cannot see.

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 externref in 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-bindgen output 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.

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