Engineering ยท 5 November 2026

NetWasmGC: a precise, non-moving garbage collector for .NET on WebAssembly

WebAssembly gives a garbage collector no stack to scan. This is how NetWasm collects .NET objects in linear memory anyway, exactly, with finalizers, weak references and pinning intact, in about 1,300 lines of C for the collector core.

No stack to scan

A .NET collector normally finds the live objects by walking the thread's stack. CoreCLR knows, at every safepoint, which stack slots and registers hold object references, and Mono can fall back to scanning the stack conservatively. Neither works inside WebAssembly. A Wasm function's locals and operand stack belong to the engine. They are not in linear memory, and a module cannot walk its own call stack. A reference held only in a Wasm local is invisible to anything the module can run.

The WebAssembly GC proposal answers this by moving objects out of linear memory into the engine's heap. That does not fit .NET. A fixed statement, a Span<T> over an array, a ref into the middle of an object and a buffer handed to C code are all linear-memory addresses into managed objects. NetWasm keeps the managed heap in linear memory and brings its own collector.

That collector also has to provide what .NET programs actually use: finalizers that may resurrect their objects, short and long weak references, ConditionalWeakTable's ephemerons, interior and one-past-the-end references, pinning, and objects held by JavaScript or native code. NetWasm started on the Boehm collector, built for Wasm and patched for memory64. It worked, but it carried tens of kilobytes of general machinery into every application, and it is gone. NetWasm now ships three choices: NetWasmGC for speed, Compact for size, and Bump, which never collects, for bounded jobs. This post is mostly about NetWasmGC.

Roots come from the compiler

A collector cannot tell an object address from an integer with the same bits by looking at memory. A conservative collector guesses, and a guess keeps garbage alive. NetWasm does not guess: the compiler knows the type of every local, argument, field and evaluation-stack value, so it tells the runtime exactly where the references are.

A method that holds references across a call that can collect gets a root frame: a run of reference-sized slots on a shadow stack in linear memory. The method's reference locals and arguments get stable slots there. Reads use a copy cached in a Wasm local, and every assignment keeps the slot in step. References that live only on the evaluation stack are published at the safepoints that need them rather than on every instruction. Within a block the compiler proves which zero stores and republications are redundant and drops them, but it still clears a slot whose reference is dead, so an object's lifetime is never stretched by an optimization.

Objects describe themselves the same way. Every type's layout lists the byte offsets of its reference fields, plus an element layout for arrays and strings, and the collector traces those offsets only. Integer fields, unmanaged memory and native stacks are never scanned. One regression test stores an object's address in an int field and checks that the object is still reclaimed.

A stack pointer for roots

The shadow stack is an arena. root_frame_enter advances a cursor by the frame's size, and leaving the method moves it back. The first 128 KiB are acquired on first use; deeper recursion links further chunks of at least 4 KiB. A collection traces each chunk from its start to its cursor, which is exactly the set of live frames, because frames are strictly last-in, first-out.

Exceptions were the expensive part. Every method with a root frame used to wrap its body in a try_table whose only job was to leave the frame and rethrow. Wasmtime treats a call inside a handler as clobbering every register, so each of those functions saved and restored all of its callee-saved registers, floating point included. A native profile of a UI-style churn workload spent 26% of its samples in those prologues and epilogues.

Native code never pays that, because a native stack pointer simply snaps back when an exception unwinds. NetWasm now does the same with its arenas. Methods leave their frames only on normal exit. Each exception region records where the root arena, the value-frame arena and the stack-trace stack stood when it began, and the landing pad that catches an exception restores those marks in one call. The cleanup-only handlers disappeared: the churn workload went from 12.32 to 10.14 ms, integer formatting took 0.77 of its time, and the benchmark module shrank from 134,417 to 123,611 bytes. Tests check that objects held only by the skipped frames become collectable after the catch.

Roots behind an address

Root frames hold what a method keeps in Wasm locals. A local whose address the method takes cannot live there: a ref, a Span<T> over a stack buffer and a struct's this all need a linear-memory address. Those locals live in the method's value frame, a second arena with the same last-in, first-out cursor.

Issue #144 showed what that split had missed. A page failed with an InvalidCastException in a library method, depending on unrelated code elsewhere in the app, because a collection had run at the wrong moment. There were two causes. Six runtime intrinsics that allocate, such as cloning a delegate's invocation list, were not safepoints, so their callers published nothing around them, and Delegate.Combine's new list was freed while the clone allocated. And a params ReadOnlySpan<Node> argument kept its nodes only in a stack inline array that the caller never read again after making the span. A local whose address is taken now stays a root at every safepoint its address reaches.

Checking the rest of that class turned up a third gap. A safepoint copied what a method held into its root frame, and a collection read only those copies, so a reference that a callee stored into its caller's value frame through a span or a ref field stayed invisible until the caller's next safepoint. NetWasmGC freed the elements a callee had just written into an inline array.

The fix traces value frames instead of publishing more. A value frame that can hold references carries a map, one bit for each reference-sized word. A map of one word lives in the frame's header, and a longer one follows the frame's bytes. A collection walks the live value frames and traces the mapped words as they are when it runs, so whatever a callee wrote is seen, and frames start zeroed, so a mapped word never holds a stale address. Safepoints no longer publish that storage at all, so a method that takes addresses keeps fewer root slots and stores less at each one. With NetWasmGC at O3, the micro-benchmark suite runs in 0.9992 of its earlier time: 0.5% slower on cases between 10 and 100 ns, and 1 to 2% faster on longer ones.

Only mapped frames join the list the collector walks. Entering a frame without references bumps the arena's cursor, and leaving it checks only that the frame lies in the arena's current chunk below the cursor before moving the cursor back; a frame in an earlier chunk takes an out-of-line path, and any other address traps. Leaving a mapped frame checks that it is the newest on the list and in the current chunk, then restores the list head and the cursor. If it is not, which happens only when frames above it were abandoned without their leaves, the out-of-line path finds it on the list or traps. The fast paths matter more than they sound. dotnet/performance passes every benchmark result through a generic method that enters a frame for a rarely taken branch, so every case paid for its leave: 10.7 ns a call while every value frame joined the list, 12.2 to 13.0 ns with this change's first leave, which inlined all its checks and the walk through earlier chunks, and 8.3 ns now.

Each class got a guard that outlives the fix. An intrinsic that is not a safepoint now stops the compilation if its code can call into the collector. The runtime has a stress build that collects before every allocation and poisons what it frees, and the release runs 384 GC tests under it on both collectors, among them 50 ways a program can hold an object while a collection runs.

Objects without a collector header

Every managed object in NetWasm starts with its type id. NetWasmGC used to put a five-word header in front of that as well: owner, descriptor, payload size, element count and element size, 32 bytes per object on wasm32. A small object paid more for its header than for its fields.

Now no collector stores anything per object. When NetWasmGC needs an object's size or references, it calls the runtime's layout function on the object's type id. An array is one allocation, its fields followed by its elements, so a new int[16] is a single object rather than an object and a separate buffer. With both changes, which shipped together in NetWasm 0.10.0, peak linear memory on our full-transaction workload fell from 36.9 to 20.9 MiB and on the retained object graph from 31.7 to 15.7 MiB.

Objects are grouped by size and by whether they contain references. A string or a byte array never needs tracing, so it lives in blocks the marker never visits.

Allocation is a pointer bump through zeroed runs

Objects under 4 KiB live in 64 KiB blocks, one slot size per block. Each block begins with its metadata, including three side bitmaps: allocated, marked and pending. Mark state lives beside the objects, not in them, so marking never writes to object memory.

A slot is always at least one byte longer than its object. .NET allows a reference one past the end of an array, as a span's end can be, and that address must name exactly one object. CoreCLR gets the gap from the header of the next object; NetWasmGC gets it from the slot.

Allocation works in runs. When a size class needs space, it finds up to 4 KiB of consecutive free slots in a block with a bitmap scan, marks them allocated, zeroes them with one memset and charges the whole run to the heap at once. Every allocation after that is a bump: compare the cursor with the run's end, advance it, return the old value. Nothing is counted per object. A run's objects and bytes are counted when it closes. .NET requires new objects to be zeroed, and zeroing a run in one pass is cheaper than zeroing each object, so the runtime does not zero them again.

Making the fast path a pure bump took 0.92 of the time on the retained object graph and 0.91 on UI churn. The bump is inlined into the runtime's allocation entry, so most allocations never reach the collector's slow path.

Blocks come from arenas that start at 16 blocks, 1 MiB, and double up to 256 blocks. Objects of 4 KiB and more are allocated individually. To find the block or large object that owns an address, NetWasmGC keeps a radix directory over 4 KiB pages: two levels on wasm32, seven on wasm64, each page naming at most two regions, with the last page cached. An exact reference resolves to its slot with one division; interior and endpoint references take a slower, general path.

Marking without a mark stack

A mark stack has to grow, and growing means allocating while the heap is being inspected. NetWasmGC does without one. Marking an object sets its mark bit and its pending bit and queues its block if the block is not already queued. Draining takes the next queued block and walks its pending bits with count-trailing-zeros, tracing each object it finds. Large objects have their own pending list. The work list is the bitmaps themselves, so a collection allocates nothing and a deep graph needs no recursion.

Sweeping is word-at-a-time: a block's marked bitmap becomes its allocated bitmap, and the population count is its live total. A block left empty goes to a shared pool that any size class can take, which is what keeps one burst of 48-byte objects from stranding memory that 200-byte objects need later. Free slots inside a surviving block are reused by the next run search. The next collection is due when the occupied heap reaches twice what survived, plus a 4 MiB floor.

Finalizers, weak references and ephemerons

Precise roots are not enough on their own; .NET's lifetime rules decide what reachable means. Both collector cores share one lifetime layer that runs between marking and sweeping:

  1. Mark ordinary roots and every pending finalizer graph, and drain ephemerons to a fixed point.
  2. Clear short weak references to unreachable objects.
  3. Snapshot every unreachable finalizable object before promoting any of them, then queue and trace the whole set.
  4. Run the ephemeron fixed point again, because a finalizable key may have just become reachable.
  5. Clear long weak references and dead ephemeron entries, then sweep.

Finalizers run after the collection returns. They may allocate, resurrect their objects and trigger another collection; the whole pending queue stays rooted while they do. Weak, ephemeron and finalizer bookkeeping lives outside the managed heap, so none of it is mistaken for a root.

Pinning costs nothing

Objects never move, so a pinned object needs no special treatment. A fixed pointer, a pinned GCHandle or a buffer passed to C code or JavaScript is just the object's address. The handle keeps the object alive; nothing has to stop it from moving. In a moving collector, pinning fragments the heap and complicates every compaction. Here it is a counter kept for diagnostics.

Interop builds on the same pieces. Objects held across the JavaScript boundary, by pending async calls, host callbacks and component resources, are referenced through a handle table the collector traces. A native callback can allocate and trigger a collection while its caller's roots and GCHandle state stay valid; that is part of the native interop test matrix on wasm32 and wasm64.

Compact, the size-first core

NetWasmGC spends code and memory on speed. Compact, the default, spends them on size. It sits behind the same interface and lifetime layer with its own small mark-sweep core: objects up to 4 KiB are packed into 64 KiB chunks behind a one-word header holding the slot size and the mark, traced and free flags. A start bitmap per chunk and an address-ordered list of chunks resolve exact, interior and one-past-the-end references. Freed slots go to free lists by size. Its mark stack grows on demand, and if it cannot grow, the collector rescans the heap for marked objects instead, so collection never fails for lack of memory.

A complete Console.WriteLine(42) component published at Oz is CONSOLE42_COMPACT bytes with Compact, CONSOLE42_NETWASM with NetWasmGC and CONSOLE42_BUMP with Bump. Only the selected collector is linked.

Results

Three workloads compiled from the same C# with NetWasm 0.10.8 at O3, Mono AOT and desktop .NET 11 RC3, every Wasm build run by Wasmtime 49.0.3 on an Apple M1 Max. Median of 15 fresh-process runs in milliseconds; every run's checksum matched desktop .NET.

WorkloadNetWasmGCCompactMono AOT.NET 11 JIT.NET 11 NativeAOT
Full transaction28.542.056.649.732.6
Retained object graph4.812.514.49.87.6
UI churn7.721.227.48.76.7

NetWasmGC takes 50%, 66% and 72% less time than Mono AOT and less than the desktop JIT on all three, and less than desktop NativeAOT on the transaction and the object graph. NativeAOT, with its generational, moving collector, is still ahead on churn. Peak linear memory was 20.9, 15.7 and 7.3 MiB with NetWasmGC and 17.4, 11.8 and 1.7 MiB with Compact; Mono reserves 50 MiB at start. The performance page has the ranges, the workloads and the command that reproduces them.

What it does not do

NetWasmGC is not generational, not concurrent and does not move objects. Every collection marks the whole live heap while the program waits, so pauses grow with what the program keeps. Size classes bound fragmentation; they do not remove it, and nothing compacts the heap. Freed memory is reused by the instance but WebAssembly memory never shrinks. Desktop .NET's collectors make different trades and win where long-lived heaps and short pauses matter most.

For a browser page, a worker, a serverless handler or a command-line tool, NetWasmGC trades those features for a collector the compiler can feed exact roots, that allocates with a pointer bump and that never stops a pinned buffer from being handed to C. Select it in the application project:

MyApp.csproj
<PropertyGroup>
  <NetWasmGarbageCollector>NetWasm</NetWasmGarbageCollector>
</PropertyGroup>

Leave it unset for Compact. Benchmark your own application before choosing; neither collector is fastest everywhere, and the collector guide lists the tradeoffs.

NetWasm

Compile the next C# program.