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-objdump for 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.

Index in, type checked, call made A runtime integer selects a slot in the table. The engine checks that the referenced function's type matches the type named at the call site, traps if it does not, and otherwise performs the call. index = 1 computed at runtime table (funcref) 0 → $add 1 → $sub type check against the call site's type call A mismatched type traps rather than calling, which is why a corrupted function pointer in WebAssembly is an error rather than an exploit. The check is per call and cheap; it compares a type index the engine already has.

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.

Shared memory, shared table, no isolation A main module and its side modules share one linear memory and one function table. Calls between them are indirect through the table, and because the memory is shared there is no isolation between them. main module MAIN_MODULE=1 side module A loaded at runtime side module B loaded at runtime one shared linear memory + one shared function table Shared memory is what makes pointers meaningful across modules — and what means none of them is isolated from the others.

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.

Everything a table unlocks A table of function references is the mechanism behind indirect calls, virtual dispatch, callbacks held by the host, and linking a second module into a running instance. call_indirect calling a function chosen at runtime virtual dispatch a vtable is a range of table slots host callbacks a slot the host can invoke by index side modules a linked module's functions appended at runtime The type check on every indirect call is what keeps all of this safe — a wrong signature traps, never runs. One table serves all four uses, which is why slot allocation has to be managed deliberately.

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

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.

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.

← Back to WebAssembly Core Concepts & Browser Runtime