deniz.in

Markets

Weather

Loading weather

· via Hacker News – Front Page (native)

NetWasm compiles C# straight to WebAssembly and undercuts Rust on binary size

NetWasm, an independent .NET toolchain, compiles C# directly into standalone WASI WebAssembly components, and its own print-42 benchmark shows a 47,798-byte binary beating Rust's 53,635 bytes.

NetWasm compiles C# straight to WebAssembly and undercuts Rust on binary size

An independent .NET stack for WebAssembly

NetWasm is an independently built .NET-for-WebAssembly toolchain — a compiler, CoreLib, runtime and SDK — that turns C# and .NET CIL directly into WebAssembly. According to the project's GitHub repository, which reached Hacker News' front page, the output does not carry the desktop CLR, Mono, dotnet.js or application DLLs: the published Wasm component is the program. The project frames itself as a new .NET platform with a deliberately smaller contract, not desktop .NET placed in a Wasm sandbox.

The claim that drew attention is that for a trivial program printing the number 42, NetWasm 0.8.0 with its precise garbage collector produces a smaller standalone artifact than Rust.

The numbers behind the comparison

Both artifacts are standalone WASI Preview 2 components built with size-oriented settings. NetWasm's binary is 47,798 bytes against 53,635 bytes for Rust 1.95.0 in a size-oriented profile — 10.9% smaller raw, and 20.4% smaller after Brotli-11 compression (13,501 versus 16,963 bytes). Crucially, the NetWasm figure includes its runtime and garbage collector.

For context, the documentation also lists Kotlin 2.4.0 wasmWasi at 79,293 bytes and TinyGo 0.39.0 at 93,043 bytes, but notes both run in WASI Preview 1 contexts with different runtime boundaries — Kotlin's garbage collection, for instance, is supplied by the host engine. The project is explicit that this is a measured result for one workload and profile, not a language-wide size ranking.

How the binary stays small

Roslyn compiles C# to CIL, and NetWasm then compiles the reachable portion of the program directly to Wasm, specialising generics and linking only the runtime support that is actually used. The documentation credits several design choices: closed-world compilation that excludes unreachable code, imports and runtime features; a smaller platform contract that drops many desktop runtime assumptions; no broad runtime reflection, with runtime type names, dynamic assembly loading and the dynamic keyword all outside the profile; generated boundaries for JSON, dependency injection, WIT and native interop instead of runtime discovery; opt-in data such as timezone databases; and linking only the single chosen garbage collector.

Developers pick from four allocation strategies. Compact, the default, is a size-first, precise, non-moving mark-and-sweep collector — "compact" refers to the implementation's size, not heap compaction — that traces compiler-published roots rather than scanning conservatively. The documentation's strategy table lists a one-shot Bump option at 43,864 bytes raw and the throughput-oriented NetWasmGC at 50,855 bytes.

Performance claims against Mono AOT

From version 0.10.0, the project reports its allocation and garbage-collection benchmarks running faster than Mono AOT, compiled from the same C# source and executed by the same Wasmtime. As medians of 15 runs on an Apple M1 Max under Wasmtime 49.0.3, it lists a full transaction at 43.9 ms versus Mono AOT's 59.1 ms (26% less), a retained object graph at 8.8 versus 14.2 ms, and UI churn at 17.6 versus 29.1 ms. NetWasmGC also reports beating desktop .NET 11's JIT on the first two workloads (49.9 and 9.7 ms) while the JIT wins UI churn at 8.6 ms; desktop NativeAOT remains fastest on all three.

The Blazor gap, with caveats

For a print-42 Blazor WebAssembly application with AOT, managed trimming, IL stripping and other size optimisations enabled, the documentation lists 12,276,684 bytes on .NET 10 (257 times NetWasm) and 15,289,241 bytes on .NET 11 RC1 (320 times). The project itself cautions that this pits a console component against an empty-UI application and is not an equivalent-framework comparison.

Workflow and current limits

Projects build through the ordinary dotnet CLI: after installing .NET SDK 10.0.303 or newer, templates create apps that restore, run and publish normally, and the published component runs on a WASI Preview 2 host such as Wasmtime. Beyond hello world, the platform claims support for C# 15, typed JavaScript module exports for pages and Web Workers, linking Wasm .a archives with generated C ABI marshalling, WIT bindings, expression trees without general reflection, packaged LINQ, HTTP, JSON, XML and Regex libraries, TUnit and VSTest integration, and wasm64. Libraries can multi-target the netwasm0.1 profile and desktop net10.0. The SDK restores its pinned Node, LLD and Binaryen tools through NuGet, so no separate Emscripten, Git or Python installation is required.

The limits are real. NetWasm is pre-1.0, and its source, package, target-profile and ABI contracts may change between minor releases. Runtime reflection, dynamic, runtime assembly loading and managed threading are outside the profile, and general file APIs, raw sockets and subprocesses are not yet implemented. Existing NuGet packages work only when their reachable code fits the supported profile.

Why it matters

Binary size decides load times on the web and cold starts at the edge, territory that has largely belonged to no-runtime languages such as Rust, C and Zig. If NetWasm's figures hold up under independent measurement, a garbage-collected .NET language shipping smaller components than Rust — runtime and GC included — would challenge the assumption that small Wasm means abandoning managed runtimes. The caveats deserve weight: these are the project's own benchmarks on a minimal workload, and the size wins come from a deliberately restricted API surface. But as a demonstration of what closed-world compilation can do for .NET, it is a notable data point.

  • #webassembly
  • #csharp
  • #dotnet
  • #compilers
  • #wasi

Related posts