Growing a Wasm Table at Runtime

This guide answers one task: add function references to a module’s table after it has been instantiated, so callbacks and handlers can be registered while the program runs — and manage the indices so the table does not grow forever.

Prerequisites

  • [ ] A module with a table declared without a restrictive maximum.
  • [ ] A case that needs registration: callbacks, plugin handlers, an event system.
  • [ ] wasm-objdump, to confirm the table’s declared bounds.
  • [ ] A plan for releasing indices, which is the part people skip.

Declaring a growable table

A table’s declaration includes a minimum and an optional maximum. Omitting the maximum makes it growable up to the engine’s own limit; specifying one caps it, and specifying the same value as the minimum makes it fixed.

(table $handlers 0 funcref)          ;; growable, no declared maximum
(table $fixed 8 8 funcref)           ;; cannot grow
(table $bounded 0 256 funcref)       ;; growable up to 256

For anything a host controls, the bounded form is right. An unbounded table in a module that registers on demand is an unbounded allocation driven by whatever drives registration, which for a plugin host is someone else’s code.

Registration, one slot at a time Each call to table.grow appends a slot holding the new reference and returns the previous size, which becomes that entry's index. The table grows monotonically unless slots are reused. initial size 0 register A A grow → 0, size 1 register B A B grow → 1, size 2 release A null B size stays 2 — tables do not shrink Which is why a free list matters: without one, a registry that churns grows forever even though most slots are empty.

Growing from inside the module

table.grow takes a reference to fill the new slots with and a count, and returns the previous size — or −1 if the table cannot grow.

(func (export "register") (param $f funcref) (result i32)
  (local $prev i32)
  (local.set $prev (table.grow $handlers (local.get $f) (i32.const 1)))
  (local.get $prev))                      ;; the new entry's index, or -1
(func (export "invoke") (param $idx i32) (param $arg i32)
  (call_indirect $handlers (type $handler) (local.get $arg) (local.get $idx)))

Checking the return value matters. A module that assumes growth succeeded and uses −1 as an index gets an undefined element trap at the call site rather than an error at the registration site, which is a considerably worse place to discover the problem.

Growing from the host

A WebAssembly.Table exposes the same operations to JavaScript, which is how a host registers its own functions into a module’s table.

const table = instance.exports.handlers;      // a WebAssembly.Table

const idx = table.grow(1);                     // previous size, or throws on failure
table.set(idx, hostCallback);                  // a JS function, or an exported wasm function

instance.exports.invoke(idx, 42);

Table.prototype.grow throws a RangeError on failure rather than returning −1, which is the JavaScript convention and differs from the instruction’s behaviour. Code that handles both paths has to handle both conventions.

Note that a JavaScript function placed in a table is called through call_indirect with the same type check as anything else, so its signature has to match. Where WebAssembly.Function is unavailable, the usual approach is a small exported trampoline that forwards to an imported function.

Allocating and reusing indices

Tables grow and never shrink, so a registry that repeatedly registers and unregisters will consume slots indefinitely unless it reuses them. A free list is the standard answer and is a dozen lines.

static mut FREE: Vec<u32> = Vec::new();

#[no_mangle]
pub extern "C" fn register(f: Function) -> i32 {
    unsafe {
        if let Some(idx) = FREE.pop() {
            table_set(idx, f);                 // reuse a released slot
            return idx as i32;
        }
    }
    table_grow(f, 1)                            // no free slot; append
}

#[no_mangle]
pub extern "C" fn unregister(idx: u32) {
    table_set_null(idx);
    unsafe { FREE.push(idx) };
}

The two halves matter equally. Nulling the slot releases the reference so the function — and anything it captures — can be collected; pushing the index onto the free list means the slot is used again rather than abandoned.

Without the null, the table keeps the reference alive even though nothing calls it, which is a leak that a heap snapshot attributes to the module rather than to the code that forgot to unregister.

A free list keeps the table bounded Releasing an index nulls the slot and records the index as free. The next registration reuses it rather than growing the table, so a churning registry stays at its high-water mark instead of growing without limit. unregister(3) table.set(3, null) free.push(3) free list: [3] slot released, reference dropped table size unchanged register(g) → 3 reuses the slot no growth An index handed out after reuse refers to a different function, so anything holding the old index must be told — which is what makes generation counters worth adding.

Generation counters, for handles that outlive their slot

Reusing an index creates a hazard: anything still holding an old index now refers to a different function. If handles are passed around — stored in a data structure, handed to a host, kept across an asynchronous boundary — that is a source of bugs that look like the wrong handler being called.

The standard fix is a generation counter packed into the handle alongside the index. Each reuse increments the generation, and a stale handle fails validation rather than silently calling something else.

// handle = (generation << 16) | index
fn make_handle(idx: u32, generation: u32) -> u32 { (generation << 16) | (idx & 0xFFFF) }

fn resolve(handle: u32) -> Option<u32> {
    let idx = handle & 0xFFFF;
    let gen = handle >> 16;
    unsafe { (GENERATIONS[idx as usize] == gen).then_some(idx) }
}

#[no_mangle]
pub extern "C" fn invoke(handle: u32, arg: i32) -> i32 {
    match resolve(handle) {
        Some(idx) => call_handler(idx, arg),
        None => -1,                            // stale handle, reported rather than misdirected
    }
}

Sixteen bits of index and sixteen of generation suits a registry of up to 65,536 entries with 65,536 reuses before wrapping, which for most applications is ample. Choose the split to match your expected churn — a short-lived registry with heavy turnover wants more generation bits than index bits.

The cost is one array lookup and a comparison per call, which is negligible next to the indirect call itself. The benefit is that a whole class of use-after-free bug becomes an explicit error, reported at the point of use, with an index you can log.

Expected output

A registry with a free list holds its size while churning, which is the property to verify:

register(a) → 0   size 1
register(b) → 1   size 2
register(c) → 2   size 3
unregister(1)     size 3, free [1]
register(d) → 1   size 3          ← reused
register(e) → 3   size 4
console.log(instance.exports.handlers.length);   // 4, not 5
console.log(instance.exports.handlers.get(1));   // the function registered as d

A size that climbs monotonically under a register-and-unregister workload means the free list is missing or the slots are not being nulled.

What a grow does to existing indices Growing appends slots at the end. Every existing index keeps pointing at the same function, which is what makes runtime linking possible at all. before grow slots 0-11 in use table size 12 after grow(8) slots 0-11 unchanged 12-19 new old indices still valid Unlike memory.grow, this does not invalidate anything the host holds — indices are stable by design. A grow can fail and returns -1; a host that assumes success writes into a slot that does not exist.

Gotchas

  • Ignoring the −1 return. The failure surfaces later as an undefined element trap at the call site.
  • Different failure conventions. The instruction returns −1; Table.prototype.grow throws.
  • Nulling the slot but not recording the index. The slot is free and nothing will ever reuse it.
  • Recording the index but not nulling. The reference stays alive; a leak with no obvious owner.
  • Stale indices after reuse. A holder of index 1 now calls a different function. Version the handle if that matters.
  • No declared maximum in a host-facing module. Unbounded growth at someone else’s discretion.

Performance note

table.grow by one entry costs on the order of a hundred nanoseconds — an allocation and a copy of the reference array in the general case, which engines amortise by over-allocating. Growing in batches when you know several registrations are coming is measurably cheaper than one at a time, and for a startup sequence registering a hundred handlers the difference was 12 microseconds against 140. The call through the table afterwards costs the same regardless of how the slot was filled.

Frequently Asked Questions

Can a table shrink? No. The only way to reduce a table’s size is to discard the instance, which is another reason a free list is the right structure for a long-lived registry.

What is the maximum size? Engine-defined, typically in the low millions of entries, and further limited by whatever maximum the module declares. Declare one.

Is there a cost to a large table that is mostly empty? A small per-slot cost for the reference and nothing else — an empty slot is a null reference. A table of ten thousand mostly-null entries is a few tens of kilobytes outside linear memory, which is rarely worth optimising against.

Does growing a table invalidate anything? No — unlike memory.grow, which detaches typed-array views, growing a table leaves existing indices valid and existing WebAssembly.Table references usable. That asymmetry surprises people who have been burned by memory growth.

Can I pre-size the table instead of growing it? Yes, and it is usually better when the count is known: declare a minimum large enough for the expected registrations and fill slots with table.set rather than growing one at a time. Growth then only happens in the unusual case, which is exactly when you want the failure path exercised.

Should the host or the module own the registry? Whichever owns the lifecycle. If the host decides when a handler is added and removed, the host should manage the table and the indices; if the module does, it should. Splitting the responsibility is what produces slots nobody frees.

A registry is easy to write and easy to leak. The free list and the generation counter are the two pieces that make it survive contact with a long-running application.

← Back to Tables & Dynamic Linking