Calling Function Pointers with call_indirect

This guide answers one task: call a function chosen at runtime from a WebAssembly module — writing the table, the element segment and the call_indirect instruction yourself, and understanding exactly what the engine checks.

Prerequisites

  • [ ] wat2wasm and wasm-objdump from the WebAssembly Binary Toolkit.
  • [ ] Comfort reading WAT; this page works at that level.
  • [ ] A case where the target is not known at compile time.
  • [ ] Node or a browser to run the result.

The instruction

call_indirect takes the arguments for the call, then an i32 index, and an immediate naming the expected function type. It looks up the table entry at that index, checks the function’s type against the named type, and calls it.

(module
  (type $binop (func (param i32 i32) (result i32)))

  (func $add (type $binop) (i32.add (local.get 0) (local.get 1)))
  (func $mul (type $binop) (i32.mul (local.get 0) (local.get 1)))

  (table 2 funcref)
  (elem (i32.const 0) $add $mul)

  (func (export "apply") (param $which i32) (param $a i32) (param $b i32) (result i32)
    (local.get $a)
    (local.get $b)
    (local.get $which)
    (call_indirect (type $binop))))
wat2wasm apply.wat -o apply.wasm
const { instance } = await WebAssembly.instantiate(await readFile('apply.wasm'));
console.log(instance.exports.apply(0, 6, 7));   // 13
console.log(instance.exports.apply(1, 6, 7));   // 42

Note the stack order: arguments first, index last. That trips people writing WAT by hand, because it reads backwards from a function call in most languages.

Arguments first, index on top The operand stack holds the call's arguments with the table index pushed last, on top. The instruction pops the index, resolves the table entry, checks its type and then consumes the arguments. operand stack, top last a = 6 b = 7 index = 1 ← popped first table[1] → $mul type checked against $binop $mul(6, 7) → 42 Writing the index before the arguments is the most common hand-written WAT error, and the validator catches it as a type mismatch on the stack.

What is checked, and when

Two checks happen at validation time and two at runtime, and knowing which is which explains the error messages.

At validation, the engine checks that the named type exists and that the operand stack matches it — the right number of arguments, of the right types, with an i32 index on top. A mismatch here fails compilation, before the module runs at all.

At runtime, the engine checks that the index is within the table’s current size, and that the function at that index has exactly the named type. Either failing produces a trap.

RuntimeError: undefined element                  ← index out of range, or the slot is null
RuntimeError: indirect call signature mismatch   ← the function's type is not the named type

The type check is by structural equality of the whole signature, not by compatibility. A function taking (i32, i32) cannot be called through a type of (i32), even though it would work in a language with default arguments.

Calling a host function through the table

A table slot can hold a JavaScript function, which is how a host installs a callback the module invokes without an import.

const table = new WebAssembly.Table({ element: 'anyfunc', initial: 4 });
const { instance } = await WebAssembly.instantiate(bytes, { env: { table } });

// a JavaScript function, wrapped so it has a WebAssembly type
const cb = new WebAssembly.Function(
  { parameters: ['i32', 'i32'], results: ['i32'] },
  (a, b) => a - b,
);
table.set(2, cb);

console.log(instance.exports.apply(2, 10, 4));   // 6, computed in JavaScript

Where WebAssembly.Function is unavailable, the conventional approach is to export a small WebAssembly trampoline that calls an imported function, and put the trampoline in the table. The module sees a table entry; the trampoline forwards to the host.

Using it from C

C code never writes call_indirect directly — it writes a function pointer, and the compiler produces the table and the instruction.

typedef int (*binop)(int, int);

static int add(int a, int b) { return a + b; }
static int mul(int a, int b) { return a * b; }

static binop ops[] = { add, mul };

int apply(int which, int a, int b) {
  return ops[which](a, b);            // a load from the array, then call_indirect
}
emcc ops.c -O2 -sSTANDALONE_WASM --no-entry -o ops.wasm
wasm-objdump -d ops.wasm | grep -B2 -A1 call_indirect
  0012a4: 20 00     | local.get 0
  0012a6: 11 01 00  | call_indirect (type 1)

The values in ops[] are table indices. Printing one shows a small integer, and comparing two function pointers compares indices — which is correct but produces different numbers than a native build would.

What a function pointer becomes An array of C function pointers compiles to an array of small integers in linear memory, which index into the module's function table. The call through the pointer becomes call_indirect with the loaded index. binop ops[] = {add, mul} in linear memory: [ 1, 2 ] index function table 1 → add 2 → mul call_indirect (type $binop) The array holds indices rather than addresses, which is why a corrupted entry produces a trap rather than a jump to arbitrary code.

Multiple tables and multiple types

Two extensions to the basic form are worth knowing, because both remove awkward workarounds.

The reference types proposal allows a module to declare more than one table, and call_indirect takes a table index as an additional immediate. That lets a module keep separate tables for separate purposes — one of event handlers, one of comparison functions, one of host callbacks — each with its own size and bounds.

(table $handlers 16 funcref)
(table $callbacks 4 funcref)

(func (export "dispatch") (param $i i32)
  (call_indirect $handlers (type $handler) (local.get $i)))

Segregating them this way makes the bounds meaningful: a handler index cannot accidentally reach a callback slot, because the bounds check is per table. With one shared table an off-by-one index silently selects a neighbouring entry of the same type, which passes the type check and calls the wrong function.

The second extension is declaring several types and using each at the appropriate call site. A module with one type for handlers and another for comparators gets the type check working for it: a comparator index used at a handler call site traps immediately rather than being called with the wrong arguments.

(type $handler (func (param i32)))
(type $comparator (func (param i32 i32) (result i32)))

Distinct types cost nothing — they are entries in the type section — and they convert a class of logic error into an immediate, localised trap. That is a good trade in any module where more than one kind of function lives behind an index.

Expected output

The working module behaves as the indices suggest, and the traps are specific:

apply(0, 6, 7) → 13
apply(1, 6, 7) → 42
apply(5, 6, 7) → RuntimeError: undefined element
# what the module declares
wasm-objdump -x apply.wasm | grep -A3 'Table\['
# Table[1]:
#  - table[0] type=funcref initial=2 max=2

A table whose maximum equals its initial size, as here, cannot grow — which means index 5 will never be valid and the trap is a programming error rather than a transient condition.

What happens on every indirect call The index selects a table slot, the engine compares the stored function's type against the type immediate, and only a match is called. A mismatch or an empty slot traps. i32 index a plain number table slot function reference type check signature compared call or trap match, or abort The type check is not optional and cannot be skipped: it is what makes an index safe to accept from anywhere. An out-of-range index traps too, so a corrupted pointer fails loudly rather than calling something arbitrary. The cost is a few nanoseconds — real, but far below the cost of crossing into JavaScript.

Gotchas

  • Index pushed before the arguments. A validation error about the stack, not about the call.
  • Type declared inline rather than referenced. Two structurally identical types are the same type, but a typo in one produces a mismatch that reads as a runtime failure.
  • Assuming slot zero is usable. Toolchains often reserve it as null so that a null function pointer traps.
  • Hard-coding an index. Indices are assigned at compile time and change between builds.
  • Expecting compatibility rather than equality. The check is exact; extra or missing parameters fail.
  • Forgetting the table can be imported. If the host supplies it, its contents may change under you.

Performance note

An indirect call costs roughly 2–5 nanoseconds more than a direct one — a bounds check, a type comparison and an unpredicted branch. Over ten million calls that measured as 41 ms against 18 ms for direct calls in the same loop. Where the same index is used every time, engine speculation closes much of the gap, bringing it to about 24 ms. None of that matters for a callback; all of it matters for an interpreter.

Frequently Asked Questions

Why is there no call_ref? There is, in the function-references proposal: it calls a typed funcref directly with no table lookup and no runtime type check, because the reference carries its type. Where it is available it is both faster and simpler than a table; call_indirect remains the universally supported mechanism.

Can I get the index of a function at runtime? In WAT, ref.func produces a funcref which you can place in a table with table.grow or table.set; the index is what those return or what you chose. In C, taking a function’s address gives you the index directly.

What happens if two functions have the same type? They are interchangeable through call_indirect — the check is on the type, not the identity. That is exactly what makes a table of handlers work, and it is the limit of what the type check protects against.

Does the type check cost anything measurable? It is a comparison of a type index the engine already has, so it is part of the few nanoseconds an indirect call costs overall rather than a separate expense. No engine offers a way to skip it, which is the point.

Is there a way to call by name at runtime? Not in the core language. A name-based lookup is something you build: a map from a string to an index, maintained on the host side or in the module’s own memory.

Written out by hand once, the instruction stops being mysterious — and the traps it produces become immediately diagnosable rather than puzzling.

← Back to Tables & Dynamic Linking