Using Multiple Tables in One Module

This page answers one question: since the reference types proposal, a WebAssembly module can have more than one table — what are additional tables for, how are they addressed, and how do they show up in compiled code?

Prerequisites

  • [ ] WABT or wasm-tools with reference types (on by default in current releases).
  • [ ] Any current browser or runtime; reference types are supported everywhere.
  • [ ] Familiarity with tables, as in exporting a table to JavaScript.

From one table of functions to many tables of references

In the MVP, a module had at most one table, and it could only hold function references (funcref). Everything indirect — C function pointers, vtables, callbacks — shared that single table. The reference types proposal generalised tables in two directions. A module may declare or import any number of tables, each with its own size and limits. And tables may hold externref — opaque references to host values such as JavaScript objects — as well as funcref.

Every table instruction gained a table index to say which table it means: table.get, table.set, table.size, table.grow, table.fill, table.copy, table.init, and call_indirect. Table 0 remains the default, so single-table modules are unchanged. The practical uses fall into three groups: separating unrelated sets of functions, holding host objects alongside functions, and giving imported and exported tables their own identity in multi-module setups.

A module with three tables One funcref table holds the compiled program's function pointers and vtables, a second funcref table holds plugin entry points registered at runtime, and an externref table holds JavaScript objects the module refers to by index. table 0 — funcref function pointers and vtables from the compiler table 1 — funcref plugin handlers registered at runtime; own growth and limits table 2 — externref JavaScript objects (DOM nodes, callbacks) by index

Step 1 — declare and use several tables

(module
  (type $handler (func (param i32) (result i32)))
  (table $code 4 funcref)                     ;; table 0: compiled-in functions
  (table $plugins (export "plugins") 0 64 funcref)    ;; table 1: grows as plugins register
  (table $objects (export "objects") 16 externref)    ;; table 2: host objects

  (func $builtin (type $handler) (i32.add (local.get 0) (i32.const 1)))
  (elem (table $code) (i32.const 0) func $builtin)

  ;; dispatch to a plugin by slot
  (func (export "run_plugin") (param $slot i32) (param $x i32) (result i32)
    (call_indirect $plugins (type $handler) (local.get $x) (local.get $slot)))

  ;; remember a JavaScript object, return its slot
  (func (export "remember") (param $obj externref) (param $slot i32)
    (table.set $objects (local.get $slot) (local.get $obj))))
wat2wasm tables.wat -o tables.wasm
wasm-objdump -x -j Table tables.wasm
Table[3]:
 - table[0] type=funcref initial=4
 - table[1] type=funcref initial=0 max=64
 - table[2] type=externref initial=16

Step 2 — fill tables from JavaScript

Exported tables are WebAssembly.Table objects; each keeps its own element type:

const { instance } = await WebAssembly.instantiateStreaming(fetch("tables.wasm"));
const { plugins, objects } = instance.exports;

// register a plugin function from another module
const plugin = await WebAssembly.instantiateStreaming(fetch("plugin.wasm"));
const slot = plugins.grow(1);
plugins.set(slot, plugin.instance.exports.handle);
console.log(instance.exports.run_plugin(slot, 41));

// store a DOM node in the externref table
instance.exports.remember(document.querySelector("#status"), 3);
console.log(objects.get(3));          // the same element

Putting plugins in their own table means the core program’s function pointers never share slots with code loaded later, and plugin slots can be reset or reassigned without any risk of disturbing compiled-in vtables. The design side is covered in designing a Wasm plugin interface.

Step 3 — hold host objects in an externref table

An externref table is the WebAssembly-native way for a module to keep references to JavaScript objects. Before reference types, glue code like wasm-bindgen’s kept a JavaScript array of objects — a heap slab — and passed integer indices into the module. With an externref table, the engine itself holds the references, the garbage collector sees them, and the module can pass them back out without any JavaScript-side lookup.

wasm-bindgen uses exactly this when built with --reference-types: its JsValue handles become slots in an externref table it manages. That is why reference types often shrink wasm-bindgen glue and make passing objects across the boundary slightly cheaper, as described in reference types and externref.

Holding JavaScript objects without and with an externref table Without reference types, glue keeps objects in a JavaScript array and the module holds integer indices into it. With an externref table, the engine holds the references directly and the module indexes the table, with no JavaScript-side lookup. JS heap slab (MVP glue) objects in a JS array module holds integer indices glue looks up the array per use works everywhere, more glue externref table objects held by the engine module indexes the table no JS lookup; GC-visible less glue, cheaper calls

Step 4 — copy between tables

table.copy copies a range between two tables of compatible element type, and table.init copies from an element segment into any table. That lets a module stage a set of handlers in one table and publish them into another in one step:

(func (export "publish") (param $dst i32) (param $src i32) (param $n i32)
  (table.copy $plugins $code (local.get $dst) (local.get $src) (local.get $n)))

Copies are bounds-checked as a whole and trap before writing anything if either range is out of bounds — the same rule as memory.copy.

Step 5 — know where toolchains use multiple tables

Hand-written multi-table code is uncommon. You are more likely to meet extra tables in modules produced by toolchains: wasm-bindgen with reference types adds an externref table; dynamic-linking setups import a shared function table alongside the module’s own; component-model adapters and some language runtimes use separate tables to keep internal dispatch apart from host-visible references. Reading wasm-objdump -j Table on a module you ship is a quick way to understand what each table is for.

Managing slots in a growing table

A table that receives entries at runtime — plugins, callbacks, host objects — needs slot management, and the patterns are worth getting right from the start. The simplest is append-only: grow the table by one for each registration and never reuse slots. It is easy to reason about and fine when registrations are few and long-lived. When entries come and go — host objects passed in for one operation, handlers that are unregistered — append-only leaks slots and the table grows forever.

The usual fix is a free list maintained alongside the table: when an entry is released, set the slot to null and push its index onto a list; when a new entry arrives, pop a free index if there is one and grow the table only if there is not. Keep the free list on whichever side owns the registrations — in JavaScript for host-driven registration, in the module for module-driven registration — and make release explicit. For externref tables in particular, clearing a slot with null matters: the reference keeps the JavaScript object alive, so a forgotten slot is a memory leak in the page, not just in the table. The same discipline appears in freeing Wasm objects with FinalizationRegistry.

Why separate tables help safety and clarity

A single shared function table means every function pointer in the program can, in principle, point at any function in that table with a matching signature. Splitting functions into separate tables by purpose narrows that: a plugin dispatch through the plugin table can only reach plugin functions, however its index is corrupted, and the core program’s vtables are unreachable from it. The same principle — partition reachable targets by purpose — is what control-flow integrity schemes in native compilers implement with extra metadata. In WebAssembly it is just a matter of declaring more than one table. It also makes code easier to reason about: a table named $plugins with its own limits documents its role in a way that a range of indices in one big table never does.

Expected output

42

…

The first line is the plugin’s result through table 1; the second is the DOM node retrieved from the externref table.

Gotchas

  • Old tools reject the module. Very old WABT, Binaryen or engine versions without reference types support. Upgrade.
  • Storing a function in an externref table. Element types must match; functions go in funcref tables. A JavaScript function can be stored as an externref value, but it cannot then be called with call_indirect.
  • Index confusion between tables. Imports come first in the index space. Use named tables in WAT.
  • Holding host objects forever. Slots in an externref table keep their objects alive. Null them when done.
  • Forgetting limits. A plugin table without a maximum can grow without bound. Set one.

Performance note

call_indirect through table 1 cost the same as through table 0 in a microbenchmark — about 1.4 ns per call in Chrome. Switching a wasm-bindgen build to reference types, which moves JsValue handles into an externref table, reduced the glue size by 14% and made passing a DOM node into Rust and back about 30% cheaper.

Passing a DOM node into Rust and back, heap slab versus externref Nanoseconds per round trip for a wasm-bindgen function taking and returning a JavaScript object, built without and with reference types. ns per round trip (warmed up) heap slab (no reference types) 61 ns externref table (--reference-types) 43 ns

Frequently Asked Questions

Is there a limit on the number of tables? Engines impose implementation limits — typically a hundred thousand or more — far beyond practical needs.

Can call_indirect use an externref table? No. Only funcref tables can be called through. externref tables hold values, not callable functions.

Do multiple tables work with threads? Tables are not shared between threads in the current proposals; each thread’s instance has its own tables.

Does the GC proposal add more table types? Yes — tables can hold typed function references and GC references, which typed call instructions use; see using Wasm GC for managed languages.

← Back to Tables & Dynamic Linking