Compiling C# to Wasm with NativeAOT-LLVM
This page answers one task: you have C# code — a library of business rules, a parser, a calculation engine — and want it as a WebAssembly module for the browser or for WASI hosts, small and fast to start, without the full .NET runtime that Blazor WebAssembly downloads. NativeAOT-LLVM is the route to explore; you want to know how it works, how to try it, and what its limits are.
Prerequisites
- [ ] A recent .NET SDK and familiarity with project files and trimming.
- [ ] Access to the experimental NativeAOT-LLVM packages (published from the
dotnet/runtimelabrepository’s feature branch). - [ ] The Emscripten SDK (for browser targets) or the WASI SDK (for WASI targets), as the toolchain’s documentation specifies.
Two ways to run C# as WebAssembly
The mainstream way to run .NET in the browser is Blazor WebAssembly (and the related wasm workloads): the .NET runtime itself is compiled to WebAssembly, and
your application’s assemblies run on it, interpreted or partially AOT-compiled. That supports almost all of .NET — reflection, dynamic loading — at the cost of
downloading the runtime and framework libraries (several megabytes even after trimming) and slower startup.
NativeAOT-LLVM takes the approach .NET’s NativeAOT uses for native executables: compile the application and the parts of the framework it uses ahead of time
into a single native image, with no JIT and no interpreter — except the target is WebAssembly, produced through LLVM. The output is a .wasm module containing
your code, a minimal runtime (garbage collector, type system support) and only the framework code your program reaches. It can be much smaller and start much
faster, but inherits NativeAOT’s constraints: no runtime code generation, limited reflection, and trimming that must see all code statically.
Step 1 — set up a project
The toolchain is experimental and its package names and properties change; follow the current instructions in the runtimelab repository. The general shape is a class library or console project that references the ILCompiler LLVM packages from the experimental feed and sets the runtime identifier to a WebAssembly target:
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <!-- or the version the experimental toolchain supports -->
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier> <!-- or wasi-wasm -->
<PublishTrimmed>true</PublishTrimmed>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.DotNet.ILCompiler.LLVM" Version="(experimental)" />
<PackageReference Include="runtime.$(NETCoreSdkPortableRuntimeIdentifier).Microsoft.DotNet.ILCompiler.LLVM" Version="(experimental)" />
</ItemGroup>
dotnet publish -c Release then invokes the AOT compiler, LLVM and the Emscripten or WASI linker to produce the module.
Step 2 — export functions to JavaScript
NativeAOT exposes functions to the outside world with UnmanagedCallersOnly, giving them C-compatible signatures and export names:
using System.Runtime.InteropServices;
public static class Exports
{
[UnmanagedCallersOnly(EntryPoint = "add_tax")]
public static double AddTax(double amount, int region) => TaxRules.Apply(amount, region);
[UnmanagedCallersOnly(EntryPoint = "validate_iban")]
public static unsafe int ValidateIban(byte* utf8, int len)
=> Iban.IsValid(System.Text.Encoding.UTF8.GetString(utf8, len)) ? 1 : 0;
}
Parameters and returns must be blittable — numbers and pointers — so strings and arrays travel through linear memory with a pointer and length, exactly as with C or raw Rust exports. Write a small JavaScript wrapper that allocates, copies, calls and frees, as described in running Rust Wasm code without JavaScript glue — the boundary is the same.
Step 3 — deal with trimming and reflection
The AOT compiler must know all code at compile time. Reflection-based serialisers (System.Text.Json without source generation), dependency-injection
containers that scan assemblies, and dynamic proxies either fail or need annotations. Use source-generated alternatives — System.Text.Json source generators,
compile-time DI — and fix trimming warnings rather than suppressing them; each warning is a potential runtime failure. Libraries that are already
“AOT-compatible” for native NativeAOT generally work here too.
Step 4 — target WASI as well
With a wasi-wasm runtime identifier, the same code produces a WASI module runnable in Wasmtime, Wasmer or other hosts — useful for server-side plugins and
edge functions. Console applications with Main become command modules; libraries export functions for hosts. Component Model support (generating bindings
from WIT) has been explored in the .NET ecosystem through tools such as componentize-dotnet; check the current state if you need components.
Step 5 — measure size and startup
Measure the module size after wasm-opt and compression, and time instantiation plus a first call. Compare with the Blazor-style runtime for the same code. For
a library of business rules, the AOT module can be a fraction of the runtime-based download and start almost instantly; for code that pulls in large parts of
the framework (globalisation data, LINQ-heavy code, XML), the advantage shrinks.
Maturity and support
NativeAOT-LLVM lives in an experimental repository. It is used by some projects and evolves actively, but it is not a supported product with stability guarantees: package versions, properties and capabilities change, and some framework areas may not work. For production applications that need .NET in the browser today, Blazor WebAssembly is the supported path; use NativeAOT-LLVM where its size and startup advantages matter and you can accept the risk, pin versions carefully, and keep a fallback.
Sharing code with server and native builds
The usual motivation for compiling C# to Wasm is reuse: the same validation, pricing or calculation code already runs on an ASP.NET server or in a desktop app, and the web front end needs identical results. Structure the shared code as a plain class library with no dependencies on ASP.NET, UI frameworks or I/O, and keep the Wasm-specific exports in a thin project that references it. The library then compiles normally for the server, AOT-compiles for the browser, and is tested once with ordinary unit tests. Watch for differences that matter for identical results across targets: culture-sensitive formatting and parsing (use invariant culture explicitly), floating-point formatting, and time-zone handling, which on Wasm builds with invariant globalisation may behave differently from a server with full ICU data. A small set of cross-target tests — run the same inputs through the server build and the Wasm module and compare outputs — catches these early.
Debugging and diagnostics
Debugging AOT-compiled C# in the browser is less convenient than debugging Blazor or native .NET. Keep logic testable natively, where full debugging works, and treat the Wasm build as a packaging step verified by tests. In the module itself, exported functions should catch exceptions at the boundary and return error codes with a message buffer rather than letting exceptions escape as traps. Keep the name section in development builds so stack traces in the browser show method names, and preserve symbol information for release builds so crash reports can be symbolicated.
Watching the project’s direction
Because the toolchain is experimental, its future shape — including how it relates to mainline .NET WebAssembly support and Component Model tooling — may change. Follow the repository’s discussions and release notes before committing a long-lived product to it.
Expected output
A tax-rules library compiles to a 1.1 MB .wasm (about 380 KB compressed) with add_tax and validate_iban exports; JavaScript calls them through a small
wrapper; trimming warnings are resolved with source-generated JSON; a WASI build of the same library runs in Wasmtime; and startup is under 20 ms versus about
600 ms for the runtime-based build.
Gotchas
- Reflection-based libraries. They fail after trimming. Use source generators.
- Suppressing trimming warnings. They become runtime failures. Fix them.
- Non-blittable export signatures. Strings and objects cannot cross directly. Pass pointers and lengths.
- Treating it as a supported product. It is experimental. Pin versions and keep a fallback.
- Globalisation data. Pulls in large tables. Use invariant globalisation where possible.
- Exceptions escaping exports. They become traps. Catch at the boundary and return error codes.
Performance note
For the business-rules library, the NativeAOT-LLVM module was about 380 KB compressed versus about 2.6 MB for a trimmed Blazor-style runtime build; first-call latency after page load was about 15 ms versus about 600 ms.
Frequently Asked Questions
Can I build Blazor UIs with NativeAOT-LLVM? Not in the mainstream sense; it targets libraries and programs, not the Blazor UI runtime.
Is garbage collection included? Yes — a minimal runtime with a GC is linked into the module.
Does it use Wasm GC? It ships its own collector in linear memory; Wasm GC-based approaches are a separate direction.
Can it call JavaScript?
Through imported functions declared with DllImport-style mechanisms the toolchain supports; check current docs.
How do I keep results identical between the server and the Wasm build? Share a plain class library, use invariant culture explicitly, and run cross-target tests on the same inputs.
How should exceptions cross the boundary? Catch them in the exported method and return an error code plus a message in a buffer; never let them escape as traps.
Where should the shared logic be debugged? Natively, with normal .NET tooling; treat the Wasm build as a packaging step verified by tests.
Related
- Running .NET in the browser — the runtime-based approach.
- Blazor WebAssembly for JavaScript developers — Blazor.
- Comparing payload size across languages — size context.
- Building components in Python and Go — other languages as components.
← Back to Other Languages in the Browser