Tracing a WebAssembly call across the JS boundary
WebAssembly's function tables are the mechanism behind indirect calls — the closest WASM equivalent to function pointers or first-class functions. A compact sample demonstrates how an exported WASM function can invoke another function through a table, even when that target function is itself imported from JavaScript.
The entire sample, a single WAT file, is available on GitHub:
(module
;; The common type we use throughout the sample.
(type $int2int (func (param i32) (result i32)))
;; Import a function named jstimes3 from the environment and call it
;; $jstimes3 here.
(import "env" "jstimes3" (func $jstimes3 (type $int2int)))
;; Simple function that adds its parameter to itself and returns the sum.
(func $wasmtimes2 (type $int2int)
(i32.add (local.get 0) (local.get 0))
)
;; Declare the dispatch function table to have 32 slots, and populate slots
;; 16 and 17 with functions.
;; This uses the WASMv1 default table 0.
(table 32 funcref)
(elem (i32.const 16) $wasmtimes2 $jstimes3)
;; The following two functions are exported to JS; when JS calls them, they
;; invoke functions from the table.
(func (export "times2") (type $int2int)
;; Place the value of the first parameter on the stack for the function
;; call_indirect will invoke.
local.get 0
;; This call_indirect invokes a function of the given type from table at
;; offset 16. The parameters to this function are expected to be on
;; the stack.
(call_indirect (type $int2int) (i32.const 16))
)
(func (export "times3") (type $int2int)
;; This is the same as times2, except it takes the function to call from
;; offset 17 in the table.
local.get 0
(call_indirect (type $int2int) (i32.const 17))
)
)
The module begins by declaring a shared function type: one i32 parameter returning i32. It then defines one imported function ($jstimes3, expected from the environment) and one local WASM function that doubles its input. Both are placed into a table declared with 32 slots of function references, starting at offset 16:
(table 32 funcref) (elem (i32.const 16) $wasmtimes2 $jstimes3)
Using offset 16 rather than 0 is deliberate — it helps surface any value-confusion bugs. Two exported functions perform the actual dynamic dispatch through the table.
From JavaScript, through the table, and back
To exercise the sample, compile the WAT file with wat2wasm from the WebAssembly Binary Toolkit:
$ wat2wasm table.wat
Node.js provides a convenient embedding environment since it mirrors the browser's WASM interface. The accompanying JavaScript loads the compiled module and supplies the jstimes3 import:
const fs = require('fs');
const wasmfile = fs.readFileSync(__dirname + '/table.wasm');
// This object is imported into wasm.
const importObject = {
env: {
jstimes3: (n) => 3 * n,
}
}
WebAssembly.instantiate(new Uint8Array(wasmfile), importObject).then(obj => {
// Get two exported functions from wasm.
let times2 = obj.instance.exports.times2;
let times3 = obj.instance.exports.times3;
console.log('times2(12) =>', times2(12));
console.log('times3(12) =>', times3(12));
});
Running this produces:
$ node table.js times2(12) => 24 times3(12) => 36
Tracing the call to times3(12) reveals the full path:
- JavaScript invokes the exported
times3function. - Inside WASM,
times3performs an indirect call through the table at offset 17, forwarding its input parameter. - That slot contains
$jstimes3, per theelemdirective. $jstimes3is an imported function from theenvobject.- The JS
env.jstimes3is defined as(n) => 3 * n, so the value 12 becomes 36.
How call_indirect interacts with the value stack
Both exported functions follow the same pattern, so examining one suffices:
(func (export "times3") (type $int2int) local.get 0 (call_indirect (type $int2int) (i32.const 17)) )
Syntactically, (type $int2int) is the only static parameter. The function index must come from the value stack, which is why i32.const 17 appears immediately before the call. The unfolded form makes the dependency clearer:
local.get 0 i32.const 17 call_indirect (type $int2int)
Omitting the type parameter compiles without complaint from wat2wasm, but fails at runtime with a trap:
RuntimeError: null function or function signature mismatch
Attempting to inline everything into a fully folded expression also fails:
(call_indirect (type $int2int) (i32.const 17) (local.get 0))
RuntimeError: null function or function signature mismatch
What makes the failure surprising is that reversing the order of the arguments makes it work:
(call_indirect (type $int2int) (local.get 0) (i32.const 17))
This seems paradoxical until you examine how the WASM stack and folded instructions actually operate.
Stack order and instruction unfolding
WASM instructions expect their arguments with the first argument pushed deepest and the last argument on top. A subtraction function illustrates the convention:
(func (export "dosub1") (param i32) (param i32) (result i32) local.get 0 local.get 1 i32.sub )
Executing i32.sub consumes a stack that looks like this:
| param 1 | <<-- top of stack |---------| | param 0 | -----------
Folded instructions are syntactic sugar that the WAT compiler unwinds into exactly the same linear sequence. The folded subtraction:
(func (export "dosub2") (param i32) (param i32) (result i32) (i32.sub (local.get 0) (local.get 1)) )
...is identical to the unfolded version, producing:
local.get 0 local.get 1 i32.sub
Why argument order matters
Revisiting the failing full fold:
(call_indirect (type $int2int) (i32.const 17) (local.get 0))
Unfolding reveals the problem:
i32.const 17 local.get 0 call_indirect (type $int2int)
When call_indirect executes, it pops the top of the stack to obtain the table index. In this unfolded sequence, the value on top is local.get 0, not the required function index — triggering the runtime trap. In contrast, the working unfolded version is:
local.get 0 i32.const 17 call_indirect (type $int2int)
Here, i32.const 17 sits on the top of the stack when call_indirect runs. Only after resolving the table entry does the dynamically-called function fetch its own parameters from the stack in the usual order — finding the local.get 0 result. That explains why the reversed folded form succeeds:
(call_indirect (type $int2int) (local.get 0) (i32.const 17))
Folded expressions are convenient when all arguments genuinely belong to the instruction or call at the head of the s-expr. For mixed cases like call_indirect, they obscure rather than clarify. Keeping the function index inside the s-expr while leaving the target function's parameters as a separate stack push better represents the two distinct stages of the call:
(func (export "times3") (type $int2int) local.get 0 (call_indirect (type $int2int) (i32.const 17)) )



