Skip to content

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/DynamicMethod unsupported.
  • 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

<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>
dotnet publish -r linux-x64

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
<PropertyGroup>
  <RunAOTCompilation>true</RunAOTCompilation>
</PropertyGroup>

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

#if NET10_0_OR_GREATER && !MONO && !NATIVEAOT
    // CoreCLR only path
#endif

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 IL2026 warnings.

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 / IL3050 warnings in CI.
  • Assuming Activator.CreateInstance(typeof(T)) always works — it does, but Activator.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.

Further reading