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) or wasm-tools parse to 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.

How call_indirect finds and checks its target The caller pushes the arguments and then the table index. call_indirect bounds-checks the index against the table size, loads the function reference, traps if it is null, compares its signature with the expected type, and only then calls the function with the arguments. push args + index a, b, op bounds check op < table.size null check slot filled? signature check matches $binop? call target result on stack

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.

call_indirect failure modes An index at or beyond the table size traps as out of bounds. A null slot traps as an uninitialised element. A function with a different signature traps as a signature mismatch. All three are runtime errors raised before the target runs. condition check V8 message fix index ≥ table.size bounds table index is out of bounds validate index or grow slot is null None null function or ... mismatch fill slot first different signature type null function or ... mismatch use matching type

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.

Dispatch cost per operation Relative cost per dispatched operation in a small interpreter using direct calls chosen by if-chains, call_indirect through a table, and a br_table over inline blocks. relative cost per operation if-chain of direct calls 1.6 × call_indirect via table 1.3 × br_table inline blocks 1 ×

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.

← Back to WebAssembly Text Format (WAT) Basics