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-toolswith 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.
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.
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
externreftable. Element types must match; functions go infuncreftables. A JavaScript function can be stored as anexternrefvalue, but it cannot then be called withcall_indirect. - Index confusion between tables. Imports come first in the index space. Use named tables in WAT.
- Holding host objects forever. Slots in an
externreftable 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.
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.
Related
- Initialising tables with element segments — filling each table.
- Using multiple memories in one module — the same idea for memory.
- Passing JS objects to Rust with wasm-bindgen — where externref tables pay off.
- Building a plugin system in the browser — a plugin table in context.
← Back to Tables & Dynamic Linking