Passing a Table at Instantiation
This page answers one task: you want a module to use a table that JavaScript creates and owns — so that JavaScript can install functions into it, so several
instances can share it, or so a plugin system can register callbacks that other modules call through call_indirect. You want to declare the import, create the
table with the right type and limits, and fill and grow it safely.
Prerequisites
- [ ] A module that imports a table, or WAT you can edit to declare one.
- [ ] Familiarity with
WebAssembly.instantiateand import objects. - [ ] Basic understanding of
call_indirectand function signatures.
What a table is
A table is an array of references held outside linear memory. Code cannot read the references as bytes; it can only call through them (call_indirect),
read and write them as reference values (table.get, table.set), and grow the table. Function pointers in C, Rust trait objects and vtables all compile to
indices into a function table, which is why almost every compiled module has one. Usually the module defines its own table and fills it from element segments.
Importing a table instead hands ownership to the host: JavaScript creates a WebAssembly.Table, passes it in the import object, and keeps a reference to it after
instantiation.
Step 1 — declare the table import
(module
(import "env" "table" (table $t 4 funcref))
(type $binop (func (param i32 i32) (result i32)))
(func (export "apply") (param $slot i32) (param $a i32) (param $b i32) (result i32)
(call_indirect $t (type $binop) (local.get $a) (local.get $b) (local.get $slot))))
The import states a minimum size (4) and the element type. An optional maximum ((table $t 4 16 funcref)) limits how large the imported table may be. The
apply function calls whatever function sits in the given slot, checking at run time that its signature matches $binop.
Step 2 — create the table in JavaScript
const table = new WebAssembly.Table({ element: "anyfunc", initial: 4, maximum: 16 });
const { instance } = await WebAssembly.instantiate(bytes, { env: { table } });
"anyfunc" is the JavaScript API name for funcref; "externref" creates a table of arbitrary JavaScript values when the reference types proposal is
supported. The table’s current size must be at least the import’s minimum, and if the import declares a maximum, the table must declare a maximum no larger than
it — otherwise instantiation fails with a LinkError.
Step 3 — fill slots with Wasm functions
Only WebAssembly functions — exports of some instance, or functions created with the type reflection proposal’s WebAssembly.Function where available — can be
stored in a funcref table. Plain JavaScript functions are rejected with a TypeError.
const { instance: ops } = await WebAssembly.instantiate(opsBytes); // exports add, mul
table.set(0, ops.exports.add);
table.set(1, ops.exports.mul);
console.log(instance.exports.apply(0, 6, 7)); // 13
console.log(instance.exports.apply(1, 6, 7)); // 42
To put a JavaScript function in a table, wrap it in a tiny Wasm module that imports the JavaScript function and re-exports it; the export is a Wasm function that can go into the table. Toolchains generate such trampolines automatically.
Step 4 — share the table between instances
Pass the same table to several instances and they all call through the same slots. This is how dynamically linked modules share function pointers: a side module’s functions are placed in slots of the main module’s table, so a function pointer created in one module can be called from another.
const shared = new WebAssembly.Table({ element: "anyfunc", initial: 8 });
const a = await WebAssembly.instantiate(aBytes, { env: { table: shared } });
const b = await WebAssembly.instantiate(bBytes, { env: { table: shared } });
Element segments in both modules write to the shared table during instantiation, so assign each module its own slot range — for example with element segment offsets taken from an imported global — or modules overwrite each other’s entries.
Step 5 — grow the table and handle empty slots
const first = table.grow(4); // returns previous length; new slots are null
table.set(first, ops.exports.add);
Growing beyond the maximum throws a RangeError. Calling an empty slot traps with “uninitialized element” (or “null function”); calling a slot whose function has
the wrong signature traps with “indirect call signature mismatch”; calling past the end traps with “table index out of bounds”. All three surface in JavaScript
as WebAssembly.RuntimeError.
A plugin registry built on an imported table
An imported table makes a simple plugin system. The host module exports a dispatch(slot, …) function that calls through the table. Each plugin is a separate
module that exports handler functions with an agreed signature. JavaScript loads plugins, appends their handlers to the table with grow and set, and tells the
host module which slot belongs to which event — by writing slot numbers into the host’s memory or passing them as arguments. Removing a plugin sets its slots back
to null so stale calls trap rather than running unloaded code. Because the signature check happens on every call_indirect, a plugin built against the wrong
interface cannot be called with mismatched arguments; it traps instead.
Imported tables versus exported tables
The alternative is to let the module define its own table and export it; JavaScript reads instance.exports.table afterwards. Exported tables are simpler when
only one module uses them and JavaScript only occasionally inspects them. Imported tables are the right choice when the host needs the table before any instance
exists, when several instances share it, or when the table’s size and maximum are a host decision. Many toolchains offer a flag for this — wasm-ld --import-table, or Emscripten’s settings for dynamic linking — so you can choose without editing WAT.
Trying it end to end in Node.js
The whole example fits in one script, which makes it a useful sanity check when a toolchain flag changes how tables are emitted. Assemble the WAT above with
wat2wasm, assemble a second module that exports add and mul, then run:
import { readFile } from "node:fs/promises";
const table = new WebAssembly.Table({ element: "anyfunc", initial: 4, maximum: 16 });
const { instance } = await WebAssembly.instantiate(await readFile("apply.wasm"), { env: { table } });
const { instance: ops } = await WebAssembly.instantiate(await readFile("ops.wasm"));
table.set(0, ops.exports.add);
table.set(1, ops.exports.mul);
console.log(instance.exports.apply(0, 6, 7), instance.exports.apply(1, 6, 7)); // 13 42
try { instance.exports.apply(2, 1, 1); } catch (e) { console.log(e.message); }
// V8: "null function or function signature mismatch"
V8 reports the empty slot and a mismatched signature with the same message, so when debugging, check table.get(slot) first: null means the slot was never
filled, while a function there means the signature differs from the type named in call_indirect.
Element segments and imported tables
A module that imports a table can still carry active element segments. During instantiation they are written into the imported table at their offsets, after the
import has been checked and before the start function runs. If an offset plus segment length exceeds the table’s current size, instantiation fails and nothing is
written. That is a common surprise when the host creates a table with exactly the minimum size and the module’s segments expect more room: create the table with
the size the toolchain reports, or read it from WebAssembly.Module.imports with type reflection where supported, and grow it before instantiating the next
module that needs additional slots.
Keeping track of slot ownership
Once several parties write to one table, slot bookkeeping becomes the main source of bugs. Keep a small JavaScript allocator next to the table — a free list of
indices, a grow call when the list is empty, and a map from slot to owner — and route every set through it. Freed slots should be set to null before they
go back on the free list, so a late call through a stale index traps instead of reaching whichever function was installed next.
Expected output
A JavaScript-created funcref table is imported by a module, filled with exported functions from another instance, called through call_indirect with the
expected results (13 and 42), shared by two instances with separate slot ranges, grown with grow, and empty or mismatched slots trap with a clear
RuntimeError.
Gotchas
- Storing plain JavaScript functions. Funcref tables need Wasm functions. Wrap them.
- Limits that do not satisfy the import. Too small or no maximum causes LinkError.
- Overlapping element segments in shared tables. Assign slot ranges per module.
- Calling null slots. Traps. Fill before calling or check for null.
- Signature mismatches. Traps at call time. Keep a single shared type definition.
- Tables sized to the bare minimum. Element segments that need more room fail instantiation. Size from toolchain output.
Performance note
call_indirect costs a bounds check and a signature check over a direct call; in a tight loop it measured roughly 1.3–1.5× the cost of a direct call, and an
indirect call that crosses into another instance through a shared table cost about the same as within one instance.
Frequently Asked Questions
Can the same table be imported by modules in a worker? No — tables cannot be posted between threads; each worker has its own tables.
Does table.get return the original exported function?
Yes — it returns the same function object that was stored.
Can a module import more than one table? Yes, with the reference types proposal, which all current browsers support.
What does “anyfunc” mean? The JavaScript API’s name for the funcref element type.
Why does V8 report the same error for empty and mismatched slots?
Both fail the same runtime check; inspect the slot with table.get to tell them apart.
When are element segments written into an imported table? During instantiation, after imports are checked and before the start function runs.
Related
- Writing an import object by hand — import objects.
- Instantiating modules that depend on each other — linking.
- Using tables and call_indirect in WAT — the instructions.
- Providing memory at instantiation — the memory equivalent.
← Back to Wasm Instantiation Lifecycle