Implementing Virtual Dispatch in Wasm
This guide answers one task: understand what a virtual method call becomes in WebAssembly, so that the performance characteristics of polymorphic code — and the traps it can produce — are predictable rather than mysterious.
Prerequisites
- [ ] A C++ or Rust codebase using virtual methods or trait objects.
- [ ]
wasm-objdumpfor reading the generated code. - [ ] Familiarity with
call_indirect— see calling function pointers. - [ ] A willingness to read a little disassembly.
What a vtable becomes
A C++ object with virtual methods carries a pointer to a vtable: an array of function pointers, one per virtual method, shared by every instance of the class. In WebAssembly the same structure exists, with one substitution — the function pointers are table indices.
object in linear memory
+0 vtable pointer (an offset into linear memory)
+4 field a
+8 field b
vtable in linear memory
+0 index of ~Shape → table[12]
+4 index of area → table[13]
+8 index of perimeter → table[14]
Everything is in linear memory except the functions themselves, which live in the module and are reached
through the table.
Reading the generated code
A virtual call compiles to a recognisable sequence, and finding it in the disassembly makes the structure concrete.
struct Shape { virtual double area() const = 0; virtual ~Shape() = default; };
struct Circle : Shape { double r; double area() const override { return 3.14159 * r * r; } };
double total(Shape** shapes, int n) {
double sum = 0;
for (int i = 0; i < n; i++) sum += shapes[i]->area(); // virtual call
return sum;
}
emcc shapes.cpp -O2 -sSTANDALONE_WASM --no-entry -o shapes.wasm
wasm-objdump -d shapes.wasm | grep -B6 -A1 call_indirect | head -20
0003f1: 28 02 00 | i32.load offset=0 ;; load the vtable pointer
0003f4: 28 02 04 | i32.load offset=4 ;; load the method index from the vtable
0003f7: 20 01 | local.get 1 ;; the object, as the this argument
0003f9: 11 03 00 | call_indirect (type 3)
Four instructions: two loads, the receiver, the call. That sequence is the whole of virtual dispatch, and recognising it in a profile tells you immediately that a hot loop is dispatching rather than computing.
Rust trait objects
A dyn Trait reference is a fat pointer: the data pointer and the vtable pointer travel together rather
than the vtable pointer being stored in the object.
trait Shape { fn area(&self) -> f64; }
struct Circle { r: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.r * self.r } }
fn total(shapes: &[Box<dyn Shape>]) -> f64 {
shapes.iter().map(|s| s.area()).sum() // virtual dispatch through the fat pointer
}
The practical difference is that the vtable pointer is not in the object’s memory, so the first load
disappears for a call through a &dyn Trait already in a local — the vtable pointer is right there in the
second half of the fat pointer. For a call through a Box<dyn Trait> in a collection, both loads happen
as in C++.
Rust’s impl Trait and generics monomorphise instead, producing a direct call with no dispatch at all,
which is why trait-heavy Rust is often faster than the equivalent C++ — the dispatch is compiled away
rather than executed.
Devirtualisation, and why it often does not happen
Where the compiler can prove the concrete type, it replaces the indirect call with a direct one. That removes both loads and the type check, and enables inlining.
Circle c{2.0};
double a = c.area(); // devirtualised: the type is known
Shape* s = &c;
double b = s->area(); // often devirtualised too, within one function
What defeats it is the usual thing: the object arriving from somewhere the compiler cannot see — a vector
of base pointers, a factory function, a plugin. Whole-program optimisation with -flto helps
substantially, because it lets the compiler see across translation units, and it is one of the larger wins
available for polymorphic C++ compiled to WebAssembly.
Where a hot loop dispatches over a collection that is homogeneous in practice, sorting by type and hoisting the dispatch out of the loop is the standard manual fix — it is the same optimisation, done by hand, because the compiler could not prove what you know.
Diagnosing a dispatch that goes wrong
When a virtual call misbehaves, the symptom is usually one of three things, and each points somewhere specific.
A signature mismatch trap means the index loaded from the vtable refers to a function of the wrong type. In a correct program that cannot happen, so it indicates memory corruption: the object’s vtable pointer is wrong, or the vtable itself has been overwritten. Look for a buffer overrun writing into the object, a use-after-free, or an object constructed in memory that was not zeroed.
An undefined element trap means the index is out of range or the slot is null — typically an uninitialised object whose vtable pointer is zero, which then reads garbage as an index.
The wrong method running without any trap is the subtlest, and it happens when the corrupted vtable pointer lands on a valid vtable for a different class with a compatible layout. The signature matches, the call succeeds, and the behaviour is wrong. This is where WebAssembly’s checks stop helping.
# a development build that catches the write rather than the call
emcc shapes.cpp -O1 -g -sASSERTIONS=2 -sSAFE_HEAP=1 -o shapes.js
SAFE_HEAP instruments every memory access and reports the out-of-bounds or misaligned write at the point
it happens, rather than leaving you to infer it from a trap several calls later. It is far too slow for
production and is the fastest route from a mysterious dispatch failure to the line responsible.
Expected output
A profile of a dispatch-heavy loop shows the cost, and the restructured version shows the difference:
1,000,000 shapes, mixed types
virtual dispatch per element 14.2 ms
sorted by type, direct calls 3.8 ms
monomorphic (all Circle) 2.1 ms
The third row is the ceiling: with one concrete type the engine’s speculation makes the indirect call nearly free, which is why a dispatch site that always sees the same type costs much less than one that alternates.
Gotchas
indirect call signature mismatchfrom a corrupted object. A wrong vtable pointer selects a mismatched function; the trap is the memory-safety property working.- Assuming devirtualisation happened. Check the disassembly rather than hoping;
-fltochanges the answer substantially. - Calling a virtual method from a constructor. The vtable pointer may not yet be the derived one, as in native C++ — the semantics are identical and equally surprising.
- Deleting through a base pointer without a virtual destructor. Undefined behaviour here as everywhere; in WebAssembly it usually corrupts the allocator rather than crashing immediately.
- Optimising dispatch without profiling. Two loads and a check is a few nanoseconds; it matters in a hot loop and nowhere else.
- Comparing vtable pointers across builds. They are offsets assigned at compile time and change freely.
Performance note
A virtual call in WebAssembly costs roughly 4–7 nanoseconds more than a direct one: two dependent loads plus the indirect call’s bounds and type check. Over a million calls that is 14.2 ms against 3.8 ms for direct calls to the same bodies — a factor of nearly four, which is entirely dispatch overhead plus the inlining that dispatch prevents. The same ratio appears in native builds, so this is not a WebAssembly penalty but the ordinary cost of polymorphism.
Frequently Asked Questions
Is virtual dispatch more expensive in WebAssembly than natively? Slightly, because of the table bounds and type check that a native indirect call does not perform. The difference is a nanosecond or two; the dominant cost in both is the dependent loads and the lost inlining.
Should I avoid virtual methods in a WebAssembly build? No more than you would natively. Use them where polymorphism is the right design, and measure before restructuring a hot loop — the overhead is real and small, and clarity is usually worth more.
Does the type check catch a corrupted vtable? It catches a mismatched signature, which a corrupted pointer usually produces. It does not catch a pointer that happens to land on a valid vtable of the same shape, so it is a safety net rather than a guarantee.
How does Wasm GC change this? For languages targeting it, dispatch can use engine-managed structures and typed references, which allows more precise checks and potentially better optimisation. For C++ compiled to linear memory, nothing changes.
Related
- Calling function pointers with call_indirect — the instruction underneath.
- Decoding Wasm opcodes for debugging — reading the sequences shown here.
- Migrating legacy C code to WebAssembly — where polymorphic C++ usually arrives from.
Understanding the four-instruction sequence is enough to read any polymorphic WebAssembly in a profiler.
← Back to Tables & Dynamic Linking