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.

Holding a JavaScript object through a table handle JavaScript passes an object to an export as externref. The module stores it in a free slot of its externref table with table.set and keeps the slot index in a struct in linear memory. Later code retrieves the object with table.get using the index. When the object is no longer needed, the module clears the slot so the object can be garbage-collected. JS passes object externref param table.set slot find free index store index in memory i32 handle table.get handle use the object clear slot when done object collectable

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.

Handles that are released versus handles that are not If slots are cleared with ref.null when handles are released and indices are reused through a free list, the table stays small and objects are collected. If slots are never cleared, every object ever retained stays alive with everything it references, and the table grows without bound. release clears the slot table.set … ref.null index reused via free list objects garbage-collected correct slots never cleared references stay in table table grows forever DOM trees retained leak

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.null on 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.grow failure. 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.

Retrieving a JavaScript object by handle Approximate nanoseconds to retrieve a JavaScript object from Wasm by handle using table.get on an externref table and by calling an imported JavaScript function that looks it up in a Map. ns per lookup (approximate) table.get (externref) 3 ns imported JS Map lookup 25 ns

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.

← Back to Tables & Dynamic Linking