The Problem WASM GC Solves
Compiling a garbage-collected language like Kotlin, Dart, or OCaml to WebAssembly has historically been painful. The traditional approach was to compile your language's runtime, including its entire garbage collector, into a WASM module. That GC would then manage objects inside WASM's linear memory, a flat byte array that the host runtime knows nothing about.
This worked, but it was terrible:
- Bloated binaries: You're shipping a full GC implementation (often 200-500KB) in every module.
- No integration with the host GC: The browser's V8 or SpiderMonkey collector has no idea what's alive or dead in your linear memory. Two GCs running independently, completely unaware of each other.
- No cycle collection across boundaries: If your WASM object holds a reference to a JS object and vice versa, neither GC can collect the cycle.
- Redundant work: The browser already has a world-class, highly-optimized garbage collector. Why bring your own?
What WASM GC Adds
The proposal introduces several new type constructs and instructions. The following sections cover the core additions.
Struct Types
;; Define a struct type for a linked list node
(type $Node (struct
(field $value i32)
(field $next (ref null $Node))
))
;; Create a new node
(struct.new $Node
(i32.const 42) ;; value
(ref.null $Node) ;; next = null
)
;; Read the value field
(struct.get $Node $value) ;; returns i32
These structs live on the host GC's heap. When nothing references them, the browser's collector reclaims them. No custom allocator, no free lists, no manual memory management.
Array Types
;; A growable array of i32
(type $IntArray (array (mut i32)))
;; Create an array of 100 elements, all initialized to 0
(array.new $IntArray
(i32.const 0) ;; init value
(i32.const 100) ;; length
)
;; Get element at index 5
(array.get $IntArray (i32.const 5))
;; Set element at index 5
(array.set $IntArray (i32.const 5) (i32.const 999))
Type Hierarchies and Casts
WASM GC supports structural subtyping:
(type $Shape (struct
(field $x f64)
(field $y f64)
))
(type $Circle (sub $Shape (struct
(field $x f64)
(field $y f64)
(field $radius f64)
)))
;; Downcast a Shape ref to a Circle ref
(ref.cast (ref $Circle)
(local.get $shape_ref)
)
;; Test if a ref is a Circle (returns i32 0 or 1)
(ref.test (ref $Circle)
(local.get $shape_ref)
)
The i31ref type is another gem, it packs a 31-bit integer into a reference, which is perfect for tagged values that languages like OCaml and Scheme rely on.
Kotlin/WASM: The First Real Production Story
JetBrains has invested heavily in Kotlin/WASM GC, and the results are impressive. The Kotlin/WASM compiler (K2 backend) now targets WASM GC by default, and the difference from the old linear-memory approach is substantial.
The following Kotlin class and its compiled output illustrate the mapping:
class LinkedList<T>(
val head: T,
val tail: LinkedList<T>? = null
) {
fun length(): Int {
var count = 0
var current: LinkedList<T>? = this
while (current != null) {
count++
current = current.tail
}
return count
}
}
The Kotlin/WASM compiler maps this to WASM GC structs:
(type $LinkedList (struct
(field $head (ref $Object))
(field $tail (ref null $LinkedList))
(field $vtable (ref $LinkedList_vtable))
))
No separate heap. No custom allocator. The browser's GC handles it all.
Binary Size Comparison
Testing a medium-complexity Kotlin Compose Multiplatform app (a task manager with about 15 screens):
| Approach | Binary Size | Load Time (3G) | Peak Memory |
|---|---|---|---|
| Kotlin/WASM (old, linear mem + GC) | 2.8 MB | 4.2s | 48 MB |
| Kotlin/WASM GC | 680 KB | 1.1s | 22 MB |
| Kotlin/JS | 1.4 MB | 2.3s | 35 MB |
That's a 75% reduction in binary size and a 54% reduction in peak memory. The load time improvement mostly comes from not having to initialize a custom GC at startup.
Dart/WASM: Flutter's Web Story Gets Real
The Flutter team has been working on Dart-to-WASM compilation, and WASM GC is the missing piece that makes it viable. Dart 3.3+ compiles to WASM GC, and Flutter Web can now use it in production.
// Dart class hierarchy
abstract class Animal {
final String name;
Animal(this.name);
String speak();
}
class Dog extends Animal {
Dog(String name) : super(name);
@override
String speak() => '$name says woof!';
}
// This compiles to WASM GC struct types with subtyping
// Dog <: Animal, with vtable dispatch for speak()
The Dart compiler maps class hierarchies directly to WASM GC's structural subtyping. Virtual method dispatch uses ref.cast and function references, no more vtable trampolines through linear memory.
Performance numbers from a Flutter Web benchmark (Wonderous app):
| Metric | dart2js | Dart/WASM GC |
|---|---|---|
| Frame time (avg) | 18ms | 8ms |
| Frame time (p99) | 45ms | 14ms |
| Initial load | 3.1s | 1.8s |
| Memory (steady state) | 62 MB | 31 MB |
The p99 frame time improvement is the one that matters most. The long GC pauses that caused poor Flutter Web frame rates are largely eliminated because the browser's incremental collector is doing the work now instead of a stop-the-world collector running inside linear memory.
Performance Analysis: Strengths and Limitations
Benchmarking WASM GC across different workloads reveals a nuanced picture. It is not universally faster.
Where WASM GC Wins
Object-heavy workloads, anything that allocates and discards lots of small objects. The browser's generational GC is incredibly optimized for this pattern. A tree-building benchmark:
// Benchmark: build and traverse a binary tree of depth 20
// 2^20 = ~1M nodes
// WASM GC (Kotlin/WASM): 145ms average, 12ms p99 GC pause
// Linear memory (C++ custom allocator): 89ms average, 0ms GC pause
// Linear memory (Kotlin old): 210ms average, 85ms p99 GC pause
// JavaScript: 320ms average, 25ms p99 GC pause
WASM GC beats the old Kotlin/WASM approach by 30%, and while raw C++ is still faster (no GC overhead at all), the gap is much smaller than you'd expect.
Where WASM GC Loses
Tight numeric loops, if you're doing matrix math or signal processing, WASM GC structs add indirection that linear memory doesn't have. You're going through reference types instead of reading raw bytes at known offsets.
;; Linear memory: direct load from known offset
(f64.load (i32.add (local.get $base) (i32.const 16)))
;; WASM GC: field access through reference
(struct.get $Matrix $m02 (local.get $mat_ref))
In a matrix multiplication benchmark (1024x1024, f64), the WASM GC version was about 15-20% slower than linear memory. Not catastrophic, but measurable.
Cross-Module GC References: The Integration Story
The really transformative part is how WASM GC objects integrate with JavaScript. A WASM GC struct reference can be passed directly to JavaScript as an opaque WebAssembly.Struct object. The browser's GC sees both the JS wrapper and the WASM struct as part of the same object graph.
// JavaScript side
const wasmModule = await WebAssembly.instantiateStreaming(
fetch('app.wasm'),
imports
);
// Get a GC-managed struct from WASM
const node = wasmModule.exports.createNode(42);
// Store it in a JS data structure
const cache = new Map();
cache.set('node1', node); // JS GC tracks this reference
// When cache is cleared, the WASM struct becomes eligible for collection
// No leak! The host GC sees the full reference graph.
This was literally impossible with linear memory. You had to manually pin objects and create weak reference tables to avoid leaks. These leaks in production environments are notoriously among the worst bugs to track down.
What's Still Missing
WASM GC isn't complete. A few notable gaps remain:
- Weak references: Not in the current spec. Languages that rely on weak refs (Java's
WeakReference, Kotlin'sWeakReference) need workarounds. - Finalizers: No finalization support. If your objects hold external resources, you need explicit cleanup patterns.
- Interior pointers: You can't take a reference to a field inside a struct. This limits some optimization patterns.
- Safari support: WebKit's implementation is still behind flags. Until Safari ships WASM GC unflagged, you need a fallback path for ~20% of web traffic.
Building for WASM GC Today
For new projects targeting WebAssembly with a garbage-collected source language, WASM GC is the recommended approach. The toolchain support is there:
- Kotlin:
kotlin-wasmtarget with K2 compiler (stable since Kotlin 2.0) - Dart/Flutter:
dart compile wasm(stable since Dart 3.3) - OCaml:
wasm_of_ocaml(experimental but promising) - Java: GraalWasm with GC support (behind a flag)
For Rust, C, and C++, WASM GC doesn't change much, you'll keep using linear memory, and that's fine. WASM GC and linear memory coexist in the same module.
The bottom line: shipping a garbage collector inside your WASM binary was always a hack. WASM GC is the proper solution, and the performance numbers prove it. The era of 3MB Kotlin/WASM bundles is over.