Initialising Tables with Element Segments
This page answers one question: a WebAssembly table starts out full of nulls — how do function references get into it, when does that happen, and what do the different kinds of element segment mean when you see them in a disassembly?
Prerequisites
- [ ] WABT (
wat2wasm,wasm-objdump) orwasm-tools. - [ ] Familiarity with tables and
call_indirect, as in calling function pointers with call_indirect.
Element segments are to tables what data segments are to memory
A module’s memory is initialised by data segments: byte strings copied into memory. Its tables are initialised by element segments: lists of function references (or other references) copied into a table. Just as with data, the original MVP had only one kind — segments copied at instantiation to a fixed offset — and the bulk memory and reference types proposals added two more.
Active segments name a table and an offset, and the engine copies them in during instantiation. This is what compilers emit for the function pointers a program uses: every function whose address is taken in C, every function in a C++ vtable, every Rust function stored in a trait object’s vtable gets a slot, filled by an active segment at startup.
Passive segments are not copied automatically. Code copies them into a table later with table.init, as often as needed, and discards them
with elem.drop. They are useful for lazy setup and for loading groups of functions on demand.
Declarative segments are never copied anywhere. They exist only to declare that certain functions may be referenced with ref.func — a
validation requirement that keeps engines from having to assume every function might be taken by reference.
Step 1 — write an active segment
(module
(type $cb (func (param i32) (result i32)))
(table $t 8 funcref)
(func $double (type $cb) (i32.mul (local.get 0) (i32.const 2)))
(func $square (type $cb) (i32.mul (local.get 0) (local.get 0)))
(func $negate (type $cb) (i32.sub (i32.const 0) (local.get 0)))
(elem (table $t) (i32.const 1) func $double $square $negate) ;; t[1..3] filled at instantiation
(func (export "apply") (param $f i32) (param $x i32) (result i32)
(call_indirect $t (type $cb) (local.get $x) (local.get $f))))
After instantiation, slots 1, 2 and 3 hold the three functions and slot 0 stays null — the convention that makes a zero function pointer a null pointer. If the segment does not fit in the table, instantiation fails with an out-of-bounds error before any code runs.
Step 2 — inspect what a compiler emitted
Compiled C and Rust modules usually contain one large active segment populating the indirect function table:
wasm-objdump -x -j Elem app.wasm | head -12
Elem[1]:
- segment[0] flags=0 table=0 count=214 - init i32=1
- elem[1] = func[17]
- elem[2] = func[18]
- elem[3] = func[45]
- elem[4] = func[46]
...
Each entry is a function that some code takes the address of. The count tells you how many indirect-call targets the program has, which is interesting for size — every function here is kept alive even if never called — and for auditing, since these are exactly the functions a corrupted function pointer could reach. In C++ and Rust, vtable entries dominate the list, as described in implementing virtual dispatch in Wasm.
Step 3 — load functions lazily with a passive segment
A passive segment keeps a group of functions out of the table until code asks for them:
(module
(type $cb (func (param i32) (result i32)))
(table $t (export "t") 8 funcref)
(func $f1 (type $cb) (local.get 0))
(func $f2 (type $cb) (i32.add (local.get 0) (i32.const 1)))
(elem $extras func $f1 $f2) ;; passive: no table, no offset
(func (export "enable_extras") (param $at i32)
(table.init $t $extras (local.get $at) (i32.const 0) (i32.const 2))
(elem.drop $extras))) ;; one-shot: free the segment
table.init copies a range of the segment into the table at a position chosen at runtime — useful when the free slot is only known then, as in
plugin systems that register handlers into the next available slots. elem.drop releases the segment’s storage once it will not be needed again;
a later table.init from a dropped segment traps.
Step 4 — understand declarative segments
With reference types, a function can produce a reference to another function with ref.func $f, and store it in a table, pass it to JavaScript
or keep it in a local. To validate a module quickly, engines require every function used with ref.func to be declared somewhere — in an
export, an active or passive segment, or a declarative segment. The declarative form exists purely for that:
(elem declare func $on_click $on_key) ;; these may be used with ref.func
(func (export "get_handler") (result funcref) (ref.func $on_click))
You will see declarative segments in modules compiled with reference types enabled, for functions passed to JavaScript as funcref values. They
cost nothing at runtime.
Step 5 — keep tables small and deliberate
Every entry in an active segment is a function the linker cannot remove, because it may be called indirectly. Large element segments are often a
sign of size bloat: trait objects and dyn dispatch in Rust, virtual methods in C++, or a big table of callbacks registered at startup. If a
module’s element segment is much larger than you expect, look at which functions are in it and whether static dispatch would do — the size
techniques in analyzing Wasm size with twiggy
will show the cost of what those entries keep alive.
How engines treat tables at instantiation
Copying an active segment is fast — an array copy of references — but it happens before the module can run, so very large tables add to
instantiation time, and every instance gets its own copy. Engines also lazily compile functions in some tiers, and a function placed in a table
must be callable through call_indirect at any moment, which can force earlier compilation or a lazy-compile stub. None of this matters for
typical modules with a few hundred entries. For modules with tens of thousands of indirect-call targets — large C++ applications, interpreters
with big dispatch tables — passive segments and lazy initialisation can shave measurable time off startup, which is the same reasoning as
reducing Wasm cold-start latency.
When you do need to shrink the list, start with the entries that came from type-erased containers — callback registries, dyn Trait
collections, std::function members — since those are where an indirect call usually replaced a direct one by convenience rather than need.
Expected output
const { instance } = await WebAssembly.instantiateStreaming(fetch("elems.wasm"));
console.log(instance.exports.apply(1, 7), instance.exports.apply(2, 7), instance.exports.apply(3, 7)); // 14 49 -7
instance.exports.apply(0, 7); // RuntimeError: null function — slot 0 was never filled
Gotchas
out of bounds table accessat instantiation. An active segment’s offset plus length exceeds the table size. Grow the table declaration.undeclared function reference. Aref.functargets a function not declared in any segment or export. Add a declarative segment.table.initafterelem.droptraps. Dropped segments are gone. Drop only when done.- Expecting a passive segment to be in the table. Passive segments do nothing until
table.initruns; calls through those slots trap with a null function until then. - Huge element segments. Every entry stays alive in the binary. Prefer static dispatch where dynamic dispatch is not needed.
Performance note
For a C++ application with 18,400 entries in its active element segment, initialising the table took about 0.6 ms of a 41 ms instantiation in
Chrome on a laptop — small, but the segment kept 310 KB of otherwise unreachable code alive. Replacing two large std::function registries
with direct calls removed 9,000 entries and 140 KB.
Frequently Asked Questions
Can element segments hold externref?
Yes — with reference types, segments can initialise externref tables with ref.null extern, and runtime code fills them with host values.
Can I see element segments in the browser? Not directly in DevTools, but an exported table can be iterated from the console after instantiation, which shows the result of the segments — every occupied slot and the function in it.
Why is slot 0 empty? Convention: compilers reserve it so that a null function pointer — value 0 — traps when called instead of calling some function.
Do element segments affect dynamic linking? Side modules carry their own element segments that the loader relocates into the shared table; see linking side modules at runtime.
Is table.init like memory.init?
Yes, deliberately — both come from the bulk memory proposal and follow the same model of passive segments copied on demand.
Related
- Exporting a table to JavaScript — reading the initialised table from the host.
- Using bulk memory operations — the data-segment equivalents.
- Reference types and externref — where declarative segments come from.
- Wasm binary format deep dive — how segments are encoded.
← Back to Tables & Dynamic Linking