Cross-Runtime: CoreCLR / Mono / NativeAOT / WASM
Key Points
- CoreCLR — the default JIT runtime for ASP.NET Core, console apps, services. Best perf, most diagnostic tooling.
- Mono — older runtime; modern role: powers .NET MAUI mobile/desktop, Blazor WebAssembly, Unity (legacy fork), embedded scenarios.
- NativeAOT — ahead-of-time compiled, no JIT, no runtime codegen. Smallest, fastest startup, but reflection/
Expression.Compile/DynamicMethodunsupported. - WASM (Mono-WASM) — Mono compiled to WebAssembly; runs in browser for Blazor WASM. AOT mode (
<RunAOTCompilation>true</RunAOTCompilation>) → bigger download, faster execution. - Senior choice: CoreCLR for servers; NativeAOT for serverless/CLI/microservices; Mono only when MAUI/Blazor-WASM/Unity demands it.
Concepts (deep dive)
Runtime matrix
| Runtime | JIT? | Reflection.Emit? | Used by |
|---|---|---|---|
| CoreCLR | Yes (RyuJIT) | Yes | ASP.NET Core, console, services |
| Mono | Yes | Yes | MAUI, Blazor WASM (interpreted), Unity |
| Mono+AOT | Partial (interp + AOT methods) | Limited | Blazor WASM AOT mode |
| NativeAOT | No | No | Self-contained CLIs, AWS Lambda, microservices |
| WebAssembly (Mono-WASM) | Interpreter | Yes (interp) | Blazor WASM debug |
| WebAssembly (Mono AOT) | No, AOT'd | No | Blazor WASM publish |
CoreCLR
- RyuJIT compiles IL → machine code as methods are invoked.
- Tiered compilation: tier 0 (quick code) → tier 1 (optimized) on hot methods.
- Dynamic PGO (.NET 8+ default) — gathers profile from tier 0 to optimize tier 1.
- Full reflection/
Expression.Compile/DynamicMethod. - Best diagnostic tooling —
dotnet-trace,dotnet-dump, OpenTelemetry, ETW.
Mono — when it matters
Modern Mono use cases: 1. .NET MAUI mobile (iOS, Android) — iOS forbids JIT, so AOT'd Mono. 2. Blazor WebAssembly — browser sandbox can't run CoreCLR. 3. Unity — fork of older Mono (separate evolution). 4. Tizen, embedded — small footprint scenarios.
Code is C#; build outputs differ. The runtime API surface is mostly identical, but performance characteristics, GC behavior, and intrinsics differ.
NativeAOT
Output: a single native binary, no JIT, no runtime codegen.
Wins: - ~10-100 ms cold start (vs ~500 ms-1s for CoreCLR). - 50% smaller deployment. - No JIT memory overhead. - Predictable perf (no tier transitions).
Loses: - No Reflection.Emit. - Limited Activator.CreateInstance (must be statically reachable). - No Expression.Compile. - No assembly loading at runtime. - Some libraries don't work (legacy serializers, dynamic proxies).
Fixed via: - Source generators (System.Text.Json SG, [LibraryImport], [LoggerMessage]). - Trim-friendly attributes ([DynamicallyAccessedMembers], [RequiresUnreferencedCode]). - IL2026/IL3050 warnings — fix all of them before shipping AOT.
Trimming vs AOT
- Trim: removes unreachable IL from a managed app. Output: smaller, but still JIT-based.
- AOT: trims + compiles to native + removes JIT. Strict superset of trim concerns.
<PublishTrimmed>true</PublishTrimmed> <!-- trim only -->
<PublishAot>true</PublishAot> <!-- AOT (also trims) -->
Blazor WASM modes
Interpreted (default) — ~5 MB download, instant build, slow execution
AOT compiled — ~15 MB download, slow build, near-native execution
For compute-heavy Blazor WASM, AOT pays off. For UI-heavy, interpreted is fine.
.NET MAUI runtime story
- iOS / Mac Catalyst — Mono AOT (no JIT allowed by Apple).
- Android — Mono with profile-guided AOT plus interpreter for cold methods.
- Windows — WinUI 3 native; either CoreCLR or NativeAOT (limited).
- Mac (Catalyst) — Mono AOT.
Performance differences
Throughput (relative):
CoreCLR Tier-1 1.00 (baseline, hottest code)
NativeAOT 0.85-1.00 (no PGO, otherwise comparable)
Mono+AOT 0.7
Mono interpreted 0.1-0.3
WASM (interpreted) 0.05-0.15
WASM AOT 0.5-0.7
Numbers vary by workload; use them as orders of magnitude only.
Diagnostic-tool support
dotnet-trace, dotnet-dump, dotnet-counters:
CoreCLR — full
NativeAOT — partial (some events; trace requires symbols)
Mono (mobile) — limited; use platform tools (Xcode Instruments, Android Profiler)
Blazor WASM — browser DevTools; .NET WASM debug proxy
Code: correct vs wrong
❌ Wrong: AOT app using Activator.CreateInstance on user-supplied type names
var t = Type.GetType(config.HandlerTypeName); // type may be trimmed away
var obj = Activator.CreateInstance(t!)!; // crash at runtime
✅ Correct: explicit registration, AOT-safe
public static class HandlerRegistry
{
private static readonly Dictionary<string, Func<IHandler>> _factories = new()
{
["order.submitted"] = () => new OrderSubmittedHandler(),
["order.cancelled"] = () => new OrderCancelledHandler(),
};
public static IHandler Resolve(string name) => _factories[name]();
}
❌ Wrong: assuming MAUI iOS allows JIT
var fn = Expression.Lambda<Func<int>>(Expression.Constant(42)).Compile();
fn(); // throws on iOS — JIT prohibited
✅ Correct: source generator or pre-baked code
Replace runtime expression compilation with compile-time source generation.
Design patterns for this topic
Pattern 1 — Runtime-aware build matrix
build:
- { runtime: CoreCLR, target: linux-x64, image: services }
- { runtime: NativeAOT, target: linux-x64, image: cli, lambda }
- { runtime: Mono+AOT, target: ios-arm64, image: maui-ios }
Different output artifacts per runtime; share source.
Pattern 2 — Cross-runtime feature flags
Be sparing — most code should run on all runtimes.
Pattern 3 — Lambda containers with NativeAOT
AWS Lambda + NativeAOT cuts cold start from ~1 s to ~150 ms. Big win for traffic-spiky functions.
Pattern 4 — Blazor WASM AOT only for compute-heavy paths
Mark specific assemblies for AOT, leave UI assemblies interpreted, trade-off start time vs runtime perf.
Pros & cons / trade-offs
| Runtime | Pros | Cons |
|---|---|---|
| CoreCLR | Mature, fastest peak, full diagnostics | Cold start ~500 ms, large image |
| NativeAOT | Tiny, fast startup, predictable | No reflection-emit, library churn |
| Mono | iOS/Browser/Unity reach | Slower, less tooling |
| Blazor WASM AOT | Near-native in browser | Larger download |
When to use / when to avoid
- Web service / monolith: CoreCLR.
- Serverless function / CLI / sidecar: NativeAOT.
- Mobile (MAUI): Mono AOT (forced for iOS).
- Browser SPA (Blazor): Mono interpreted for development, AOT for compute paths in production.
- Avoid mixing reflection-heavy libraries with NativeAOT — verify all
IL2026warnings.
Interview Q&A
Q1. Why is JIT forbidden on iOS? A. Apple's security model disallows W^X exception for non-system processes — pages can be writable or executable, not both. JIT requires writing then executing. AOT compiles ahead of time.
Q2. What does NativeAOT give up? A. Reflection.Emit, Expression.Compile, DynamicMethod, dynamic assembly loading, runtime type instantiation of trimmed types. Source generators replace most use cases.
Q3. What's Dynamic PGO? A. .NET 8+ feature: tier-0 JIT instrumented to gather call/branch profiles; tier-1 JIT uses that profile for inlining/devirtualization. Improves throughput ~10-30%. Disabled in NativeAOT (no JIT).
Q4. Difference between trimming and AOT? A. Trim: remove unreachable code from managed assemblies, output trimmed IL. AOT: trim + compile to native + drop the JIT. AOT is a strict superset.
Q5. Why is Blazor WASM slower than server-side Blazor? A. WASM runs in a browser sandbox; the Mono runtime is itself executed by the browser's WASM engine. CoreCLR (server-side) runs natively on the server CPU.
Q6. Can a NuGet library support both CoreCLR and NativeAOT? A. Yes — annotate reflection-using APIs with [RequiresUnreferencedCode] / [RequiresDynamicCode] so consumers see warnings under AOT. Provide source-generator alternatives where possible.
Q7. What's the deployment difference between CoreCLR and NativeAOT? A. CoreCLR: framework-dependent (~5 MB) or self-contained (~70 MB) — needs runtime. NativeAOT: single native binary (~10-30 MB), no runtime, lower memory overhead.
Q8. Why does Mono ship two compilers (AOT + interpreter)? A. AOT covers most code; interpreter handles methods AOT can't (dynamic, reflection-heavy). Together: predictable startup with full functionality.
Gotchas / common mistakes
- Targeting NativeAOT but using a library that needs reflection internally — runtime exceptions.
- Ignoring
IL2026/IL3050warnings in CI. - Assuming
Activator.CreateInstance(typeof(T))always works — it does, butActivator.CreateInstance(Type)may not under AOT. - Using
Json.NET(Newtonsoft) under AOT — works but slower; prefer System.Text.Json source-gen. - Blazor WASM start time complaint — switch to AOT only for hot paths.
- MAUI iOS using
[Linker.ProtectFromBeingPreserved]decorations — old Mono linker syntax. - Mixing trim/AOT settings between projects in a solution — analyzers warn at build.