Storing externref Values in Tables
This page answers one task: a WebAssembly module needs to hold on to JavaScript objects — DOM nodes, callbacks, CanvasRenderingContext2Ds, promises — across
calls, and refer to them from data structures in linear memory. Linear memory can only store numbers. You want to keep the objects in a Wasm table of
externref and use table indices as handles, without leaking them.
Prerequisites
- [ ] Reference types support (all current engines).
- [ ] WAT reading skills, or a toolchain that exposes tables.
- [ ] Code that needs to retain JavaScript values beyond a single call.
Why tables
An externref value is an opaque reference to a host object. It can live in Wasm locals, globals, the operand stack and tables — but not in linear memory,
because memory holds bytes and references must remain visible to the garbage collector. To store a reference “inside” a data structure (a struct in memory that
says “this node’s element is …”), you store the reference in a table slot and keep the slot’s index — an ordinary i32 — in memory. The table keeps the
object alive; the index acts as a handle.
This is the same idea wasm-bindgen uses internally when reference types are enabled: Rust’s JsValue holds an index into a table of externrefs managed by the
glue. Understanding the mechanism helps when writing low-level code, debugging retention, or building your own bindings.
Step 1 — declare a table and store references
(module
(table $objs (export "objs") 64 externref)
(global $next (mut i32) (i32.const 0))
(func (export "retain") (param $obj externref) (result i32)
(local $idx i32)
(local.set $idx (global.get $next))
(if (i32.ge_u (local.get $idx) (table.size $objs))
(then (drop (table.grow $objs (ref.null extern) (i32.const 64)))))
(table.set $objs (local.get $idx) (local.get $obj))
(global.set $next (i32.add (local.get $idx) (i32.const 1)))
(local.get $idx))
(func (export "get") (param $h i32) (result externref)
(table.get $objs (local.get $h))))
retain stores an object and returns its handle; get returns the object for a handle. table.grow adds slots (filled with ref.null extern) when the table is
full; it returns −1 if it cannot grow.
Step 2 — free slots and reuse them
The simple counter above never frees anything: every retained object stays alive as long as the instance. Real code needs a free list. Keep freed indices in a
stack (in linear memory or a second table of i32s via memory), and clear the slot when releasing so the garbage collector can reclaim the object:
(func (export "release") (param $h i32)
(table.set $objs (local.get $h) (ref.null extern)) ;; drop the reference
(call $push_free (local.get $h))) ;; make the index reusable
Forgetting to clear the slot is a leak: the table keeps the object (and anything it references, such as detached DOM trees) alive indefinitely.
Step 3 — share the table with JavaScript
An exported table is a WebAssembly.Table in JavaScript, readable and writable from both sides:
const { objs, retain, get, release } = instance.exports;
const h = retain(document.querySelector("#canvas"));
console.log(objs.get(h) === get(h)); // true
release(h);
console.log(objs.get(h)); // null
JavaScript can also create a table (new WebAssembly.Table({ element: "externref", initial: 64 })) and pass it as an import, which lets several instances share one
handle space — useful when multiple modules exchange handles for the same objects.
Step 4 — guard against stale handles
A handle used after release returns whatever now occupies the slot — null, or a different object if the index was reused. Add a generation counter (stored
alongside the index in memory, compared on use) or validate handles in debug builds, exactly as with any handle-based design. Because table access is
bounds-checked, an out-of-range handle traps rather than corrupting memory.
Step 5 — know the toolchain equivalents
You rarely write this by hand in Rust: wasm-bindgen with reference types does it for you, and JsValue’s Drop releases the slot. In C/C++, Emscripten and
clang expose tables of externref through builtins and the __externref_t type in recent versions, with restrictions (externref values cannot be stored in
memory or taken by address). Reading this mechanism helps interpret heap snapshots — retained objects show the table as their retainer — and design APIs that do
not leak.
A free list without linear memory
The free list can itself live in Wasm constructs rather than linear memory, which is convenient in hand-written modules. One approach keeps a second table of
funcref or a global array emulated with a memory region; a simpler one threads the free list through a parallel i32 array in memory where entry i holds
the next free index. Allocation pops the head; release pushes the index back after clearing the externref slot. Whatever the representation, keep the
invariant that a slot is either in use (non-null reference, not in the free list) or free (null reference, in the free list), and check it in debug builds — a
slot that is both, or neither, is a bug that eventually shows up as a stale or leaked object.
Lifetimes across asynchronous code
JavaScript objects held in tables often outlive the call that stored them — a callback registered now and invoked later, a promise resolved after several
event-loop turns. Make ownership explicit in the API: the function that stores an object returns a handle, and some other operation (a component unmounting, a
request completing, a subscription cancelled) must release it. Tie releases to those lifecycle events in the JavaScript wrapper, using FinalizationRegistry
as a safety net for wrappers that get garbage-collected without an explicit release. Without that discipline, handle tables become a slow leak that heap
snapshots reveal only after hours of use.
Debugging what a table holds
Because exported tables are visible to JavaScript, a debug helper can iterate over table.length, count non-null entries and summarise their types — “312 HTMLDivElement,
4 Function, 1 CanvasRenderingContext2D”. Logging that summary after significant UI changes shows quickly whether released objects really disappear; a count that
only ever increases points to missing releases.
Funcref tables for callbacks
The same handle pattern works for functions: a funcref table holds Wasm functions (or JavaScript functions wrapped as Wasm functions), and an index stored in
memory acts as a function pointer. Callbacks registered from JavaScript can live in either kind of table — as externref if the module only passes them back to
JavaScript, as funcref if the module calls them directly with call_indirect. Calling an externref callback requires an imported helper that invokes it,
which is simpler but goes through JavaScript each time.
Expected output
The module retains DOM elements in an externref table, stores handles in a scene graph in linear memory, releases them when nodes are removed (clearing slots and
reusing indices), and a heap snapshot after removing 1,000 nodes shows no detached elements retained by the table.
Gotchas
- Never clearing slots. Objects leak with everything they reference. Set
ref.nullon release. - Unbounded growth without reuse. The table grows forever. Use a free list.
- Stale handles. Released indices get reused. Add generations or validation.
- Trying to store externref in memory. Not allowed. Store indices.
- Ignoring
table.growfailure. It returns −1. Handle it. - Releases not tied to lifecycle events. Handles leak slowly. Release on unmount, completion or cancellation.
Performance note
table.get and table.set are cheap bounds-checked operations — a few nanoseconds — far cheaper than calling into JavaScript to look up an object in a Map by
numeric ID.
Frequently Asked Questions
Can a table hold both functions and objects?
No — a table has one element type; use separate funcref and externref tables.
Do tables need a maximum? Optional for non-shared tables; set one to bound growth.
Is externref the same as anyref?
With the GC proposal, externref is the external subtype of a broader hierarchy; for JavaScript objects, externref is the type to use.
Can several modules share one table?
Yes, by importing the same WebAssembly.Table.
How can I see what a table currently holds? Iterate the exported table from JavaScript and summarise non-null entries by type; a constantly rising count means missing releases.
Should callbacks be stored as externref or funcref? As funcref if the module calls them with call_indirect; as externref if it only hands them back to JavaScript.
Can FinalizationRegistry release handles automatically? As a safety net, yes; explicit releases tied to lifecycle events are still the primary mechanism.
What invariant should a handle table keep? Each slot is either in use with a non-null reference or free with a null reference and listed in the free list — never both.
Related
- Reference types and externref — the value type.
- Using wasm-bindgen with reference types — the toolchain view.
- Growing a Wasm table at runtime — table growth.
- Using heap snapshots to find Wasm-related leaks — finding retention.
← Back to Tables & Dynamic Linking