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

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.

The three kinds of element segment Active, passive and declarative element segments compared on when they take effect, what instructions use them, and what compilers typically emit them for. kind takes effect used with typical use active at instantiation (none: automatic) function pointers, vtables passive when table.init runs table.init, elem.drop lazy groups of functions declarative never copied ref.func declaring referenceable functions

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.

Active and passive segments over a module's life At instantiation the engine copies the active segment into the table. Later, code runs table.init to copy a passive segment into chosen slots, and elem.drop releases it. Declarative segments are never copied; they only permit ref.func. engine module code table instantiate: active segment → t[1..214] table.init $extras at slot 215 elem.drop $extras call_indirect t[215] → extra function

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 access at instantiation. An active segment’s offset plus length exceeds the table size. Grow the table declaration.
  • undeclared function reference. A ref.func targets a function not declared in any segment or export. Add a declarative segment.
  • table.init after elem.drop traps. Dropped segments are gone. Drop only when done.
  • Expecting a passive segment to be in the table. Passive segments do nothing until table.init runs; 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.

Element segment size and the code it keeps alive Number of active element entries and the module size for a C++ application before and after replacing two std::function callback registries with direct calls. module size (KB) before: 18,400 table entries 2,310 KB after: 9,400 table entries 2,170 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.

← Back to Tables & Dynamic Linking