Tables & Dynamic Linking
WebAssembly has no pointers to functions. A call names a function index that is fixed in the binary, which
makes direct calls fast and verifiable and makes anything dynamic — a callback, a virtual method, a plugin
loaded at runtime — impossible on its own. Tables are the answer: a table holds function references, and
call_indirect calls the one at an index computed at runtime, with a type check to keep it safe.
Prerequisites
- [ ] Familiarity with the module structure — functions, types, exports.
- [ ]
wasm-objdumpfor inspecting tables and element segments. - [ ] A case that needs indirection: callbacks, dispatch, or dynamic loading.
- [ ] For dynamic linking, Emscripten 3.1.60+ or a hand-built equivalent.
What a table is
A table is an array of references with a declared element type — funcref for functions, externref for
host values — a minimum size and an optional maximum. A module can have several, and a table can be
imported and exported like anything else.
(module
(type $binop (func (param i32 i32) (result i32)))
(func $add (type $binop) (i32.add (local.get 0) (local.get 1)))
(func $sub (type $binop) (i32.sub (local.get 0) (local.get 1)))
(table $ops 2 funcref)
(elem (i32.const 0) $add $sub) ;; populate slots 0 and 1
(func (export "apply") (param $op i32) (param $a i32) (param $b i32) (result i32)
(call_indirect (type $binop) (local.get $a) (local.get $b) (local.get $op))))
The elem segment fills slots at instantiation. The index passed to call_indirect is an ordinary
integer computed at runtime, which is what makes the call dynamic — and the type annotation is what keeps
it safe.
Element segments, and how a table gets filled
A table is empty at instantiation unless something fills it, and element segments are the mechanism. They come in three forms, and the difference matters when debugging why a slot is null.
An active segment names a table and an offset, and the engine copies its entries into the table during instantiation. This is the ordinary case and what a compiler emits for a program’s function pointers.
A passive segment sits in the module unused until an explicit table.init instruction copies it. That
allows a module to populate a table lazily, or to install different sets of handlers depending on a runtime
condition.
A declarative segment declares that certain functions may be referenced without providing any initial
placement, which is what lets ref.func name a function the module has not otherwise put in a table.
(elem (i32.const 0) $add $sub) ;; active: filled at instantiation
(elem $late func $slow_path $fast_path) ;; passive: copied on demand
(elem declare func $maybe_referenced) ;; declarative: makes ref.func legal
(func (export "install_fast")
(table.init $ops $late (i32.const 4) (i32.const 1) (i32.const 1)))
When a call_indirect traps with an undefined element, the first thing to check is which kind of segment
was supposed to fill that slot and whether the code that fills it ran. A passive segment that nothing ever
initialises leaves the slot null forever, and the trap points at the call site rather than at the missing
initialisation.
The type check is the security property
In a native program a function pointer is an address, and a corrupted one transfers control somewhere arbitrary. That is the mechanism behind a large family of exploits.
In WebAssembly a function “pointer” is a table index, and call_indirect checks the referenced function’s
signature against the type named at the call site. A mismatch traps. An index past the end of the table
traps. A null slot traps.
The consequence is that memory corruption inside a module cannot redirect control flow to an arbitrary location — the worst it can do is call a different function of the same signature that is already in the table. That is a meaningfully smaller blast radius, and it is one of the reasons WebAssembly is a reasonable place to run code you do not fully trust.
Where function pointers come from
A C or Rust program that uses function pointers compiles into exactly this. The compiler collects every function whose address is taken, puts them in the table, and replaces each pointer with its index.
typedef int (*binop)(int, int);
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int apply(binop f, int a, int b) { return f(a, b); } // becomes call_indirect
binop pick(int which) { return which ? sub : add; } // returns a table index
Looking at the generated module makes this concrete:
wasm-objdump -x app.wasm | grep -A4 'Elem\['
# Elem[1]:
# - segment[0] flags=0 table=0 count=2 - init i32=1
# - elem[1] = func[7] <add>
# - elem[2] = func[8] <sub>
The values a C program passes around as binop are the integers 1 and 2. That is also why printing a
function pointer in a WebAssembly build shows a small number rather than an address.
Growing a table at runtime
Tables can grow, which is what makes runtime registration possible — a plugin system adding handlers, a callback registry, an interpreter installing new dispatch targets.
(func (export "register") (param $f funcref) (result i32)
(table.grow $handlers (local.get $f) (i32.const 1)))
table.grow returns the previous size, which becomes the index of the newly added entry, or −1 if the
table cannot grow. From JavaScript, the same table is a WebAssembly.Table with grow, get and set.
const t = instance.exports.handlers;
const idx = t.grow(1);
t.set(idx, myExportedFunction); // a function from another instance, or a JS function
That last line is worth noting: a table slot can hold a function from a different instance entirely, which is the mechanism underneath dynamic linking.
Dynamic linking, and what it costs
Emscripten’s dynamic linking builds a main module and one or more side modules that share a memory and a
table. The side module’s functions are appended to the shared table at load time, and calls between
modules go through call_indirect.
emcc main.c -sMAIN_MODULE=1 -o main.js
emcc plugin.c -sSIDE_MODULE=1 -o plugin.wasm
const main = await createMain();
await main.loadDynamicLibrary('/plugin.wasm', { loadAsync: true });
main.ccall('use_plugin', 'number', [], []);
The costs are real. A main module compiled for dynamic linking cannot use the aggressive optimisations that assume a closed world, so it is larger and slower. Every cross-module call is indirect. And the shared memory means the modules are not isolated from one another — this is dynamic linking, not sandboxing, and a side module can read and corrupt the main module’s memory.
For loading untrusted code, separate instances with no shared memory are the right structure, as described in loading untrusted plugins safely. Dynamic linking is for splitting your own program.
Performance: direct, indirect and the gap between them
An indirect call is more expensive than a direct one, and knowing by how much prevents both premature avoidance and careless overuse.
A direct call is a known target the engine can inline, specialise and sometimes eliminate entirely. An indirect call is an index, a bounds check, a type comparison and a call through a pointer the engine usually cannot predict — so it is not inlined and it costs a few nanoseconds more.
In practice the difference is 2–5 nanoseconds per call on a modern engine. That is irrelevant for a callback invoked on a click, noticeable in a loop over a million elements, and dominant in an interpreter dispatching one indirect call per instruction.
10 million calls, same function body
direct call 18 ms
call_indirect 41 ms
call_indirect, monomorphic (same target every time) 24 ms
The third row is worth noting: engines speculate on the target of an indirect call and do considerably better when it is stable. A dispatch site that always calls the same function costs much less than one that alternates unpredictably, which is the same inline-cache behaviour JavaScript engines exhibit.
The practical advice follows from that. Use direct calls where the target is known, which the compiler arranges for you. Do not restructure code to avoid indirection at a callback boundary. And where an indirect call sits in the hottest loop in the program — an interpreter, a virtual machine — consider whether the dispatch can be restructured so each site sees fewer distinct targets.
Tables as a capability surface
For a host running code it did not write, the table is worth thinking about as part of the interface rather than as an implementation detail.
A module’s table tells you what it can call indirectly. A table whose maximum equals its initial size cannot grow, so its set of indirect targets is fixed at instantiation and visible in the element segments. A growable table means the module can install new targets at runtime, which is more flexible and more to reason about.
More importantly, a table the host supplies is a table the host controls. Importing a table into a module rather than letting it declare its own means the host decides its size, its maximum and its initial contents — and can inspect or modify it afterwards.
const table = new WebAssembly.Table({ element: 'anyfunc', initial: 8, maximum: 32 });
table.set(0, hostCallbackA);
table.set(1, hostCallbackB);
const instance = await WebAssembly.instantiate(module, { env: { table } });
// later, revoke a capability by nulling its slot
table.set(1, null);
That last line is a capability revocation: the module keeps the index it was given, and calling through it now traps rather than reaching the function. For a plugin host that wants to withdraw permission after a deadline or a policy change, it is a clean mechanism with no cooperation required from the guest.
The same property makes a shared table a risk in the other direction. If several modules share one table, each can call anything in it, which is why dynamic linking’s shared table is a component of its lack of isolation rather than an independent feature.
Gotchas and failure modes
RuntimeError: indirect call signature mismatch. The function at that index has a different type. Usually a stale index, or a table populated in a different order than assumed.RuntimeError: undefined element. The slot is null, or the index is past the table’s end.- Assuming index zero is unused. Many toolchains reserve slot zero as a null function; do not assume either way without checking the element segment.
- A table with no maximum in a plugin host. Unbounded growth is unbounded host memory.
- Treating dynamic linking as isolation. Shared memory means no isolation at all.
- Comparing function pointers across builds. Indices are assigned at compile time and change between builds; never persist one.
Verifying what is in a table
wasm-objdump shows the table’s declaration and its element segments, which together tell you what a
module can call indirectly:
wasm-objdump -x app.wasm | grep -A2 'Table\['
# Table[1]:
# - table[0] type=funcref initial=42 max=42
wasm-objdump -x app.wasm | grep -c 'elem\['
# 41
A table whose maximum equals its initial size cannot grow, which tells you the module has a fixed set of indirect targets — useful to know when auditing what a third-party module can do.
Debugging an indirect call that traps
Two errors account for nearly every indirect-call failure, and each has a short diagnostic path.
A signature mismatch means the function at that index has a different type from the one the call site declared. The usual causes are an index computed from stale data, a table filled in a different order than the code assumes, or — in a dynamically linked build — a side module loaded into slots the main module had already accounted for differently.
# what is actually at index 7, and what type does it have
wasm-objdump -x app.wasm | grep -A20 'Elem\[' | sed -n '8p'
wasm-objdump -x app.wasm | grep -m1 'func\[7\]'
An undefined element means the slot is null or out of range. Check the table’s declared size against the index, and check whether the segment meant to fill that slot is active or passive — a passive segment nothing initialised leaves a null slot that looks exactly like an out-of-range index from the error message alone.
From the host side, reading the table directly is often the fastest answer:
const t = instance.exports.__indirect_function_table;
console.log(t.length, t.get(7)); // null, or a function
__indirect_function_table is the conventional export name from Emscripten and wasm-bindgen builds, and
exporting it deliberately in your own modules costs nothing and makes this kind of investigation possible
at all.
Guides in this topic
- Calling function pointers with call_indirect — the instruction and its type check in detail.
- Linking side modules at runtime — Emscripten’s dynamic linking, and its costs.
- Growing a Wasm table at runtime — registration, from both sides.
- Implementing virtual dispatch in Wasm — how vtables compile.
Frequently Asked Questions
Why not just use an index into a switch?
For a small fixed set, that is often faster and simpler — a br_table jump is cheaper than an indirect
call. Tables are for cases where the set is not known at compile time, which includes any callback from
the host.
Can a table hold host functions?
Yes. A JavaScript function placed in a table with table.set can be called by the module through
call_indirect, subject to the same signature check, which is how callbacks from a host are usually
implemented.
Do multiple tables help? They allow segregating targets by purpose — one table of handlers, one of callbacks — which improves clarity and lets each have its own bounds. Support is part of the reference types work and is available in current engines.
How does this relate to the component model? The component model builds on these mechanisms to express typed interfaces between components, with generated adapters rather than hand-managed indices. The table remains underneath.
Can I inspect a table from the host at runtime?
Yes: a WebAssembly.Table exposes length and get, and a funcref retrieved from it is a callable
JavaScript function. That makes a table a useful debugging surface as well as a mechanism.
Why does my function pointer print as 3? Because that is what it is — an index into the table, not an address. Code that formats pointers for diagnostics will produce small integers in a WebAssembly build, which is correct and confusing the first time it happens.
Is there a limit on table size? Engines impose one, typically in the low millions of entries, and a module can declare its own maximum which is usually the more relevant constraint. For a registry that grows with user activity, setting a maximum is worth doing deliberately rather than discovering the engine’s.
Do tables cost memory?
A small amount per slot for the reference, outside linear memory. A table of a few thousand entries is
negligible; one growing without bound is a leak like any other.
Related
- Post-MVP Wasm Proposals in Practice — reference types, which extend what a table can hold.
- Plugin Systems & Extensibility — dynamic behaviour with isolation, unlike dynamic linking.
- Wasm Binary Format Deep Dive — where tables and element segments live in the file.
Tables are where WebAssembly’s static safety meets dynamic behaviour, and understanding them explains both what the format permits and why it is safe to run code you did not write.