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.instantiate and import objects.
  • [ ] Basic understanding of call_indirect and 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.

Importing a JavaScript-created table JavaScript creates a WebAssembly.Table with an element type and limits. It passes the table in the import object. The module's element segments, if any, are written into the table during instantiation. Afterwards JavaScript and every instance that imported the table see the same slots, and call_indirect uses the current contents. new WebAssembly. Table element + initial import object { env: { table } } instantiate elem segments applied JS sets slots table.set(i, fn) call_indirect uses current slot

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.

What can go into a table slot Funcref tables accept exported Wasm functions and null, and reject plain JavaScript functions unless they are wrapped. Externref tables accept any JavaScript value but cannot be used with call_indirect. value funcref table externref table callable via call_indirect exported Wasm function yes yes yes (funcref only) plain JS function no (TypeError) yes no None yes yes traps when called object, string, number no yes no

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.

Cost of direct and indirect calls Approximate nanoseconds per call for a direct Wasm call, a call_indirect within one instance, and a call_indirect through a shared table into a function exported from another instance. ns per call (approximate) direct call 1 ns call_indirect same instance 1.4 ns call_indirect other instance 1.5 ns

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.

← Back to Wasm Instantiation Lifecycle