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.
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.
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.
Gotchas
- Ignoring the −1 return. The failure surfaces later as an
undefined elementtrap at the call site. - Different failure conventions. The instruction returns −1;
Table.prototype.growthrows. - 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.
Related
- Calling function pointers with call_indirect — calling what you registered.
- Reference types and externref — tables of host values rather than functions.
- Designing a Wasm plugin interface — where registration usually appears.
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