Using Tables and call_indirect in WAT
This page answers one task: you want a WAT module to choose at run time which function to call — an opcode dispatcher, a jump table, a callback — and you want to
write the table, the element segment and the call_indirect yourself, understand the checks the engine performs, and recognise the traps when something is
wrong.
Prerequisites
- [ ]
wat2wasm(WABT) orwasm-tools parseto assemble modules. - [ ] Node.js or a browser console to run them.
- [ ] Comfort with WAT functions, locals and types.
Why tables exist
WebAssembly code cannot hold a raw code address. call takes a function index fixed at compile time, so dynamic dispatch needs another mechanism: a table is an
array of function references, and call_indirect takes an index into it as a run-time i32. Because the table holds references rather than addresses, a module
can never jump into the middle of a function or call something that is not a function — which is part of WebAssembly’s control-flow integrity. C function pointers,
Rust trait objects and C++ virtual calls all compile to call_indirect through a table.
Step 1 — declare a type, a table and an element segment
(module
(type $binop (func (param i32 i32) (result i32)))
(table $ops 4 funcref)
(elem (table $ops) (i32.const 0) func $add $sub $mul)
(func $add (type $binop) (i32.add (local.get 0) (local.get 1)))
(func $sub (type $binop) (i32.sub (local.get 0) (local.get 1)))
(func $mul (type $binop) (i32.mul (local.get 0) (local.get 1)))
(table $ops 4 funcref) declares a table with 4 slots. The active element segment writes $add, $sub and $mul into slots 0, 1 and 2 during
instantiation; slot 3 stays null. Naming the type with (type $binop …) lets call_indirect refer to it and guarantees all three functions share it.
Step 2 — call through the table
(func (export "calc") (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)))
In folded form, the arguments come first and the table index last — the index is the top of the stack when call_indirect executes. The (type $binop)
annotation is required: it tells the validator what the call consumes and produces, and tells the engine which signature to check at run time.
const { instance } = await WebAssembly.instantiate(bytes);
const { calc } = instance.exports;
calc(0, 6, 7); // 13
calc(1, 6, 7); // -1
calc(2, 6, 7); // 42
Step 3 — trigger the three traps
try { calc(3, 1, 1); } catch (e) { console.log(e.message); } // slot 3 is null
try { calc(9, 1, 1); } catch (e) { console.log(e.message); } // index past the end
In V8 the first prints “null function or function signature mismatch” and the second “table index is out of bounds”. Both are WebAssembly.RuntimeErrors.
The third trap is a signature mismatch: put a function of a different type in a slot and call it as $binop.
(func $neg (param i32) (result i32) (i32.sub (i32.const 0) (local.get 0)))
(elem declare func $neg)
(func (export "install_neg") (table.set $ops (i32.const 3) (ref.func $neg)))
After install_neg, calc(3, 1, 1) traps again with the same V8 message — the slot is filled, but $neg takes one parameter, not two. The signature check
compares the whole type, so a function that happens to take compatible arguments but returns nothing also fails.
Step 4 — use the table instructions
(func (export "size") (result i32) (table.size $ops)) ;; 4
(func (export "grow") (param $n i32) (result i32)
(table.grow $ops (ref.null func) (local.get $n))) ;; old size, or -1
(func (export "is_set") (param $i i32) (result i32)
(i32.eqz (ref.is_null (table.get $ops (local.get $i)))))
table.get and table.set read and write references; ref.func $f creates a reference to a function, which must be declared — by appearing in any element
segment or in (elem declare func …) — so the engine knows which functions can escape as references. table.grow returns the previous size, or −1 if growth
fails.
Step 5 — export the table to JavaScript
(export "ops" (table $ops)))
JavaScript sees a WebAssembly.Table: instance.exports.ops.length is 4, ops.get(0) returns the exported-function wrapper for $add, and ops.set(3, fn)
installs any Wasm function — though calc will still trap unless its type is $binop. Exporting the table lets JavaScript inspect or patch dispatch, which is
useful for testing and plugins.
A small bytecode interpreter
Tables shine in dispatch loops. An interpreter reads an opcode byte from memory, uses it as the table index, and call_indirects the handler. Compared with a
large br_table of blocks, a table of handler functions keeps each handler small and separately compiled, and lets you swap handlers at run time with
table.set. Compilers make the same choice for switch statements: dense integer cases become br_table, while function pointer arrays become tables.
Reading the binary encoding
call_indirect encodes as 0x11 followed by two LEB128 immediates: the type index, then the table index (in the original MVP encoding the second byte was a
reserved zero, which is the same thing for table 0). The element segment for slots 0–2 appears in the element section (id 9) as a flag byte, an offset expression
41 00 0b (i32.const 0, end) and a vector of three function indices. Disassembling the assembled module with wasm-objdump -x shows both segments and
their function indices:
Elem[2]:
- segment[0] flags=0 table=0 count=3 - init i32=0
- elem[0] = ref.func:0
- elem[1] = ref.func:1
- elem[2] = ref.func:2
- segment[1] flags=3 table=0 count=1
- elem[0] = ref.func:3
The second segment, with flags 3, is the declarative one: it puts nothing into any table and exists only so that ref.func $neg validates. Recognising both kinds helps when
reading compiler output, where large element segments fill the indirect function table that backs every function pointer in the program.
Function pointers in compiled code
When C code takes the address of a function, the linker assigns it a slot in the module’s single indirect function table, and the “pointer” is that slot number.
That is why printing a function pointer in C compiled to Wasm gives a small integer like 3, and why comparing function pointers works: equal slot, equal
function. Slot 0 is conventionally left null so that a null function pointer traps when called, matching native behaviour. Reading a compiler’s element section
therefore tells you exactly which functions are reachable through pointers — useful when hunting a call that lands in the wrong place.
Testing dispatch tables
A small set of tests covers most mistakes: call every filled slot with known inputs, call one past the last filled slot and one past the table end and expect
traps, and, after any table.set, read the slot back with table.get from JavaScript to confirm it holds the expected function. Keeping the slot numbers in one
place — named constants in the host code, or a comment block above the element segment — prevents the slow drift where a handler is added to the segment but the
caller’s opcode numbering is not updated.
Expected output
The module assembles, calc(0..2, 6, 7) returns 13, −1 and 42; calling slot 3 traps while it is null and again after a function with the wrong signature is
installed; an index of 9 traps as out of bounds; table.size returns 4; and JavaScript reads the exported table’s slots.
Gotchas
- Index pushed before the arguments. The index must be last. Fold it last.
- Missing type annotation. call_indirect requires
(type …). - Undeclared ref.func. Validation fails. Add
(elem declare func $f). - Same-looking signatures. Types must match exactly, including results.
- Assuming empty slots are zero. They are null and trap.
- Opcode numbers drifting from slot order. Keep slot constants in one place next to the element segment.
Performance note
A call_indirect adds a bounds check and a signature check to a direct call; in a tight dispatch loop it measured about 1.3× the cost of a direct call, and a
br_table over inline handlers was faster still for very small handlers.
Frequently Asked Questions
Can a module have several tables? Yes — name each and pass the table name to call_indirect.
What is funcref? The reference type for functions; tables of externref hold JavaScript values instead.
Does the signature check compare type names? No — it compares structure; two identical types declared separately match.
Can I call a table slot from JavaScript?
Yes — table.get(i) returns a callable exported function.
Why is a C function pointer a small integer in Wasm? It is a slot number in the indirect function table, not a code address.
Why is slot 0 usually empty in compiled modules? So that calling a null function pointer traps, as it would natively.
Related
- Defining functions and locals in WAT — function syntax.
- Using folded expressions in WAT — argument order.
- Passing a table at instantiation — imported tables.
- Writing loops and branches in WAT — br_table.
← Back to WebAssembly Text Format (WAT) Basics