Exporting a Table to JavaScript

This page answers one task: make a module’s function table visible to JavaScript so the host can inspect, call or replace entries — for example to plug in a different implementation of a callback, or to call a C function pointer it received from the module.

Prerequisites

What a table holds

WebAssembly code cannot hold raw code addresses. Function pointers in C, trait objects and closures in Rust, and virtual methods in C++ are all compiled into indices into a table: a resizable array of references to functions. A call through a pointer becomes call_indirect, which reads the table at that index, checks that the function there has the expected signature, and calls it. Tables keep function pointers safe — an index can only ever select a real function of the right type — while still allowing dynamic dispatch.

By default a module’s table is private. Exporting it hands the host a WebAssembly.Table object through which JavaScript can do three things: read an entry, which yields a callable JavaScript function wrapping the Wasm function; write an entry, replacing it with another WebAssembly function; and grow the table. That makes the table a meeting point between the module’s function pointers and the host’s code.

A function table shared between a module and JavaScript The module's table holds references to functions. C function pointers are indices into it, and call_indirect reads it with a type check. When exported, JavaScript sees the same table as a WebAssembly.Table and can get, set and grow entries. C: int (*op)(int, int) = add; a function pointer is just index 3 call_indirect (type $binop) reads table[3], checks signature, calls table funcref[8] [null, sub, mul, add, div, …] JavaScript: exports.__indirect_function_ table get(3) → callable; set(3, fn); grow(n)

Step 1 — export the table

In WAT, export it like any other entity:

(module
  (type $binop (func (param i32 i32) (result i32)))
  (table $ops (export "ops") 4 funcref)
  (func $add (type $binop) (i32.add (local.get 0) (local.get 1)))
  (func $mul (type $binop) (i32.mul (local.get 0) (local.get 1)))
  (elem (i32.const 0) $add $mul)                       ;; ops[0]=add, ops[1]=mul

  (func (export "apply") (param $op i32) (param $a i32) (param $b i32) (result i32)
    (call_indirect $ops (type $binop) (local.get $a) (local.get $b) (local.get $op))))

For C and C++, link with --export-table; the table is then exported as __indirect_function_table, the name LLVM’s toolchain uses. Emscripten exports it by default in most configurations, and Rust cdylib builds can pass -C link-arg=--export-table.

clang --target=wasm32 -O2 -nostdlib -Wl,--no-entry -Wl,--export-table -Wl,--export=apply ops.c -o ops.wasm
wasm-objdump -x -j Export ops.wasm

Step 2 — read and call entries from JavaScript

const { instance } = await WebAssembly.instantiateStreaming(fetch("ops.wasm"));
const table = instance.exports.ops;                  // or __indirect_function_table

console.log(table.length);                           // 4
const add = table.get(0);
console.log(add(2, 3));                              // 5 — a normal JS-callable function
console.log(table.get(2));                           // null — empty slot
console.log(instance.exports.apply(1, 6, 7));       // 42 — via call_indirect

table.get(i) returns an exported function: a JavaScript function wrapping the Wasm function, converting arguments and results the same way instance.exports functions do. When a C function hands JavaScript a function pointer — say, as a callback registration — JavaScript can turn that integer into something it can call with table.get(ptr).

Step 3 — replace an entry

table.set(i, fn) stores a function reference. With MVP semantics the value must be a WebAssembly function — an export from this or another module — not an arbitrary JavaScript function:

const other = await WebAssembly.instantiateStreaming(fetch("fast-ops.wasm"));
table.set(0, other.instance.exports.add_saturating);   // replace ops[0]
console.log(instance.exports.apply(0, 2_000_000_000, 2_000_000_000));   // saturated result

From now on, every call_indirect through index 0 calls the new function, provided it has the same signature. If it does not, the swap succeeds but the next indirect call traps with indirect call signature mismatch. To put a plain JavaScript function into a table you need either a small Wasm wrapper that imports it, or Emscripten’s addFunction, which builds such a wrapper — see registering JavaScript callbacks with addFunction.

Swapping a table entry from JavaScript JavaScript reads the exported table, sets index 0 to a function exported from a second module, and the original module's call_indirect through index 0 now calls the new function after its signature check passes. JavaScript module A table module B instantiate; take exports.add_saturating table.set(0, add_saturating) exports.apply(0, a, b) call_indirect [0] → type check passes → call saturated sum returned

Step 4 — grow the table

Tables grow like memories, by a number of entries, up to an optional maximum:

const before = table.grow(4);       // returns the old length; new slots are null
table.set(before, other.instance.exports.add_saturating);

Growth matters for dynamic linking, where newly loaded code needs slots for its functions, and for runtimes that register callbacks over time. Unlike memory growth, table growth does not invalidate anything JavaScript holds — existing entries keep their indices. Runtime growth inside the module uses table.grow, covered in growing a Wasm table at runtime.

Step 5 — decide whether to export at all

Exporting the table exposes the module’s indirect-call targets to whoever holds the exports. For your own application code that is fine and often useful. For a module that runs untrusted plugins, or a plugin you run inside a host, it is a capability worth thinking about: a host that can rewrite table entries can change which code the module runs behind its back. Export the table when JavaScript genuinely needs it — dynamic linking, callbacks, debugging — and leave it private otherwise.

Debugging with the exported table

An exported table is also a debugging tool. When a C or Rust module crashes in call_indirect with a signature mismatch, the question is always “what was at that index?” — and with the table exported, the console answers it directly. Log the function pointer value on the C side before the call, then inspect the slot from JavaScript:

const fn = table.get(57);
console.log(fn?.name, fn?.length);     // the Wasm function's index-based name and its parameter count

The function’s name is its index in the module’s function space, which you can look up in the name section or a disassembly to find which C function it is. The length is its parameter count, often enough to spot a mismatch — a callback expected to take two arguments that takes three. Iterating the whole table to list every occupied slot gives a picture of all indirect-call targets the module has, useful when auditing what a vtable-heavy C++ module can call indirectly.

Keep such inspection to debug builds or development tooling. Shipping a module with its table exported only so a debugging helper can read it widens the module’s interface for every page that loads it.

Why tables are typed and checked

The type check on every call_indirect can look like overhead, but it is what makes function pointers safe in WebAssembly. In native code, a corrupted function pointer jumps anywhere in the address space; attackers exploit exactly that. In WebAssembly a corrupted pointer is just a wrong index: it either selects a valid function of the correct signature — a logic bug, contained — or fails the bounds or signature check and traps. It can never land in the middle of a function or in data. Engines make the check cheap by comparing canonicalised signature identifiers, and in optimized code it is a single compare and branch next to the indirect call.

Expected output

4
5
null
42

and after the swap, apply(0, 2000000000, 2000000000) returns 2147483647 from the saturating version instead of wrapping to a negative number.

Gotchas

  • TypeError: WebAssembly.Table.prototype.set(): Argument 1 is invalid for table: function-typed object expected. You passed a plain JavaScript function. Only WebAssembly functions (or null) fit a funcref table.
  • RuntimeError: null function or function signature mismatch. The slot is empty or holds a function of a different type. Check the signature of whatever was stored.
  • The table is not in the exports. It was not exported at link time. Add --export-table or export it in WAT.
  • Growing past the maximum. table.grow throws a RangeError if the table declares a maximum it would exceed. Declare a generous maximum or none for tables that grow.
  • Stale indices after rebuilding. Function pointer values are link-time decisions and change between builds. Never persist them.

Performance note

Calling a function obtained with table.get from JavaScript costs the same as calling an ordinary export — a few nanoseconds of boundary crossing. Inside the module, call_indirect cost about 1.4 ns per call against 0.9 ns for a direct call in a microbenchmark, the difference being the table load and signature check.

Cost per call by call kind Nanoseconds per call in Chrome for a direct call inside the module, call_indirect through the table, and calling the same function from JavaScript via table.get. ns per call (warmed up) direct call (inside Wasm) 0.9 ns call_indirect (inside Wasm) 1.4 ns table.get(i)(…) from JS 2.3 ns

Frequently Asked Questions

Can two modules share one table? Yes — export it from one and import it into the other. That is the basis of dynamic linking, where side modules add their functions to the main module’s table.

What does externref change? Tables can also hold externref — opaque JavaScript values — and a module can have several tables of different element types; see using multiple tables in one module.

Is the table index the same as a C function pointer? Yes, in LLVM-compiled code a function pointer’s integer value is its table index. Index 0 is reserved for null.

Can JavaScript functions be stored with the type reflection proposal? The type reflection proposal adds new WebAssembly.Function(type, jsFn), which makes JavaScript functions storable directly where supported. Check support before relying on it.

← Back to Tables & Dynamic Linking