Exporting a Table to JavaScript
This page answers one task: make a module’s function table visible to JavaScript so the host can inspect, call or replace entries — for example to plug in a different implementation of a callback, or to call a C function pointer it received from the module.
Prerequisites
- [ ] WABT for the hand-written examples, or a C/Rust module linked with
--export-table. - [ ] Familiarity with
call_indirect, as in calling function pointers with call_indirect.
What a table holds
WebAssembly code cannot hold raw code addresses. Function pointers in C, trait objects and closures in Rust, and virtual methods in C++ are
all compiled into indices into a table: a resizable array of references to functions. A call through a pointer becomes call_indirect,
which reads the table at that index, checks that the function there has the expected signature, and calls it. Tables keep function pointers
safe — an index can only ever select a real function of the right type — while still allowing dynamic dispatch.
By default a module’s table is private. Exporting it hands the host a WebAssembly.Table object through which JavaScript can do three things:
read an entry, which yields a callable JavaScript function wrapping the Wasm function; write an entry, replacing it with another WebAssembly
function; and grow the table. That makes the table a meeting point between the module’s function pointers and the host’s code.
Step 1 — export the table
In WAT, export it like any other entity:
(module
(type $binop (func (param i32 i32) (result i32)))
(table $ops (export "ops") 4 funcref)
(func $add (type $binop) (i32.add (local.get 0) (local.get 1)))
(func $mul (type $binop) (i32.mul (local.get 0) (local.get 1)))
(elem (i32.const 0) $add $mul) ;; ops[0]=add, ops[1]=mul
(func (export "apply") (param $op i32) (param $a i32) (param $b i32) (result i32)
(call_indirect $ops (type $binop) (local.get $a) (local.get $b) (local.get $op))))
For C and C++, link with --export-table; the table is then exported as __indirect_function_table, the name LLVM’s toolchain uses.
Emscripten exports it by default in most configurations, and Rust cdylib builds can pass -C link-arg=--export-table.
clang --target=wasm32 -O2 -nostdlib -Wl,--no-entry -Wl,--export-table -Wl,--export=apply ops.c -o ops.wasm
wasm-objdump -x -j Export ops.wasm
Step 2 — read and call entries from JavaScript
const { instance } = await WebAssembly.instantiateStreaming(fetch("ops.wasm"));
const table = instance.exports.ops; // or __indirect_function_table
console.log(table.length); // 4
const add = table.get(0);
console.log(add(2, 3)); // 5 — a normal JS-callable function
console.log(table.get(2)); // null — empty slot
console.log(instance.exports.apply(1, 6, 7)); // 42 — via call_indirect
table.get(i) returns an exported function: a JavaScript function wrapping the Wasm function, converting arguments and results the same way
instance.exports functions do. When a C function hands JavaScript a function pointer — say, as a callback registration — JavaScript can turn
that integer into something it can call with table.get(ptr).
Step 3 — replace an entry
table.set(i, fn) stores a function reference. With MVP semantics the value must be a WebAssembly function — an export from this or another
module — not an arbitrary JavaScript function:
const other = await WebAssembly.instantiateStreaming(fetch("fast-ops.wasm"));
table.set(0, other.instance.exports.add_saturating); // replace ops[0]
console.log(instance.exports.apply(0, 2_000_000_000, 2_000_000_000)); // saturated result
From now on, every call_indirect through index 0 calls the new function, provided it has the same signature. If it does not, the swap succeeds
but the next indirect call traps with indirect call signature mismatch. To put a plain JavaScript function into a table you need either a
small Wasm wrapper that imports it, or Emscripten’s addFunction, which builds such a wrapper — see
registering JavaScript callbacks with addFunction.
Step 4 — grow the table
Tables grow like memories, by a number of entries, up to an optional maximum:
const before = table.grow(4); // returns the old length; new slots are null
table.set(before, other.instance.exports.add_saturating);
Growth matters for dynamic linking, where newly loaded code needs slots for its functions, and for runtimes that register callbacks over time.
Unlike memory growth, table growth does not invalidate anything JavaScript holds — existing entries keep their indices. Runtime growth inside
the module uses table.grow, covered in
growing a Wasm table at runtime.
Step 5 — decide whether to export at all
Exporting the table exposes the module’s indirect-call targets to whoever holds the exports. For your own application code that is fine and often useful. For a module that runs untrusted plugins, or a plugin you run inside a host, it is a capability worth thinking about: a host that can rewrite table entries can change which code the module runs behind its back. Export the table when JavaScript genuinely needs it — dynamic linking, callbacks, debugging — and leave it private otherwise.
Debugging with the exported table
An exported table is also a debugging tool. When a C or Rust module crashes in call_indirect with a signature mismatch, the question is
always “what was at that index?” — and with the table exported, the console answers it directly. Log the function pointer value on the C side
before the call, then inspect the slot from JavaScript:
const fn = table.get(57);
console.log(fn?.name, fn?.length); // the Wasm function's index-based name and its parameter count
The function’s name is its index in the module’s function space, which you can look up in the name section or a disassembly to find which
C function it is. The length is its parameter count, often enough to spot a mismatch — a callback expected to take two arguments that takes
three. Iterating the whole table to list every occupied slot gives a picture of all indirect-call targets the module has, useful when auditing
what a vtable-heavy C++ module can call indirectly.
Keep such inspection to debug builds or development tooling. Shipping a module with its table exported only so a debugging helper can read it widens the module’s interface for every page that loads it.
Why tables are typed and checked
The type check on every call_indirect can look like overhead, but it is what makes function pointers safe in WebAssembly. In native code, a
corrupted function pointer jumps anywhere in the address space; attackers exploit exactly that. In WebAssembly a corrupted pointer is just a
wrong index: it either selects a valid function of the correct signature — a logic bug, contained — or fails the bounds or signature check and
traps. It can never land in the middle of a function or in data. Engines make the check cheap by comparing canonicalised signature identifiers,
and in optimized code it is a single compare and branch next to the indirect call.
Expected output
4
5
null
42
and after the swap, apply(0, 2000000000, 2000000000) returns 2147483647 from the saturating version instead of wrapping to a negative number.
Gotchas
TypeError: WebAssembly.Table.prototype.set(): Argument 1 is invalid for table: function-typed object expected. You passed a plain JavaScript function. Only WebAssembly functions (ornull) fit afuncreftable.RuntimeError: null function or function signature mismatch. The slot is empty or holds a function of a different type. Check the signature of whatever was stored.- The table is not in the exports. It was not exported at link time. Add
--export-tableor export it in WAT. - Growing past the maximum.
table.growthrows aRangeErrorif the table declares a maximum it would exceed. Declare a generous maximum or none for tables that grow. - Stale indices after rebuilding. Function pointer values are link-time decisions and change between builds. Never persist them.
Performance note
Calling a function obtained with table.get from JavaScript costs the same as calling an ordinary export — a few nanoseconds of boundary
crossing. Inside the module, call_indirect cost about 1.4 ns per call against 0.9 ns for a direct call in a microbenchmark, the difference being
the table load and signature check.
Frequently Asked Questions
Can two modules share one table? Yes — export it from one and import it into the other. That is the basis of dynamic linking, where side modules add their functions to the main module’s table.
What does externref change?
Tables can also hold externref — opaque JavaScript values — and a module can have several tables of different element types; see
using multiple tables in one module.
Is the table index the same as a C function pointer? Yes, in LLVM-compiled code a function pointer’s integer value is its table index. Index 0 is reserved for null.
Can JavaScript functions be stored with the type reflection proposal?
The type reflection proposal adds new WebAssembly.Function(type, jsFn), which makes JavaScript functions storable directly where supported.
Check support before relying on it.
Related
- Tables & dynamic linking — the section overview.
- Implementing virtual dispatch in Wasm — how vtables use the table.
- Linking side modules at runtime — sharing a table between modules.
- Exporting symbols with wasm-ld flags —
--export-tableamong other export flags.
← Back to Tables & Dynamic Linking