Native AOT in .NET — Ahead-of-Time Compilation for 10x Faster Startup

Posted on: 4/26/2026 5:18:15 PM

You deploy an ASP.NET Core API to Kubernetes. Each pod takes 300-500ms to start, consumes 150MB RAM, and the container image weighs 200MB. When auto-scaling from zero pods, cold start causes the first request to timeout. The solution? Native AOT — compile .NET directly to machine code at publish time, no JIT runtime needed. Result: 5-30ms startup, only 30-60MB RAM, 20-40MB container image.

5-30msStartup time (vs 300-500ms JIT)
30-60MBMemory usage (vs 100-150MB JIT)
~20MBContainer image size
80%Cold start reduction

1. What is Native AOT?

In the traditional model, .NET compiles C# into IL (Intermediate Language), then the JIT (Just-In-Time) compiler converts IL to machine code at runtime. Native AOT (Ahead-of-Time) changes this completely: IL is compiled to machine code at publish time, producing a standalone executable — no .NET runtime needed, no JIT.

graph LR
    subgraph "JIT (Traditional)"
        A["C# Code"] --> B["IL (DLL)"]
        B --> C["JIT Compiler"]
        C --> D["Native Code"]
        E[".NET Runtime"] --> C
    end
    subgraph "Native AOT"
        F["C# Code"] --> G["IL (DLL)"]
        G --> H["AOT Compiler"]
        H --> I["Native Binary"]
    end
    style A fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style F fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#e94560,stroke:#fff,color:#fff
    style I fill:#4CAF50,stroke:#fff,color:#fff
    style E fill:#2c3e50,stroke:#fff,color:#fff

JIT compiles at runtime (requires .NET runtime), AOT compiles at publish time (standalone binary)

Why "Ahead-of-Time"?

Because compilation happens before the application runs (ahead of time), instead of while running (just in time). The result is a single binary file, similar to what Go or Rust produces. No dependency on .NET SDK or runtime on the target machine.

2. When Should (and Shouldn't) You Use Native AOT?

ScenarioNative AOTTraditional JIT
Serverless / AWS Lambda / Azure FunctionsIdeal — ultra-fast cold startSlow cold start, higher cost
Kubernetes scale-from-zeroPod ready in millisecondsPod needs hundreds of ms
CLI tools / Lightweight microservicesSmall binary, runs instantlyRequires runtime on target
IoT / Edge computingLow RAM, small diskToo heavy for constrained devices
MVC + Razor ViewsNot supportedOnly option
Heavy Reflection usageMust refactor or use source genWorks normally
Plugin system / dynamic loadingNot feasibleFully supported

3. Getting Started with Native AOT in ASP.NET Core

3.1 Create Project from Template

dotnet new webapiaot -n MyAotApi
cd MyAotApi

The webapiaot template auto-configures: PublishAot=true, CreateSlimBuilder, JSON Source Generator. This is the best starting point.

3.2 Project File Structure

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>

InvariantGlobalization significantly reduces binary size

When enabled, .NET skips ICU data (~30MB) used for locale-specific formatting. If your API doesn't need locale-specific date/currency formatting, this is a valuable optimization.

3.3 Program.cs with CreateSlimBuilder

using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain
        .Insert(0, AppJsonSerializerContext.Default);
});

var app = builder.Build();

var todosApi = app.MapGroup("/todos");

todosApi.MapGet("/", () => new[]
{
    new Todo(1, "Deploy Native AOT", false),
    new Todo(2, "Benchmark cold start", true)
});

todosApi.MapGet("/{id}", (int id) =>
    id switch
    {
        1 => Results.Ok(new Todo(1, "Deploy Native AOT", false)),
        2 => Results.Ok(new Todo(2, "Benchmark cold start", true)),
        _ => Results.NotFound()
    });

todosApi.MapPost("/", (Todo todo) =>
{
    return Results.Created($"/todos/{todo.Id}", todo);
});

app.Run();

// === Records and Source Generator ===
public record Todo(int Id, string Title, bool IsComplete);

[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(Todo[]))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }

Every type in HTTP body MUST be in JsonSerializerContext

This is mandatory with Native AOT. System.Text.Json cannot use reflection in AOT — you must use source generators to create serialization code at compile time. Missing a type declaration → runtime exception.

4. CreateSlimBuilder vs CreateBuilder

CreateSlimBuilder() is the "trimmed down" version of CreateBuilder(), keeping only the most essential features. Key differences:

graph TD
    A["CreateBuilder()"] --> B["Full Features"]
    B --> B1["IIS Integration"]
    B --> B2["HTTPS / HTTP3"]
    B --> B3["EventLog / Debug / EventSource logging"]
    B --> B4["Hosting Startup Assemblies"]
    B --> B5["UseStartup<T>()"]
    B --> B6["Static Web Assets"]
    B --> B7["Regex Route Constraints"]

    C["CreateSlimBuilder()"] --> D["Minimal Features"]
    D --> D1["appsettings.json config"]
    D --> D2["User Secrets"]
    D --> D3["Console logging"]
    D --> D4["Environment variables"]

    style A fill:#2c3e50,stroke:#fff,color:#fff
    style C fill:#4CAF50,stroke:#fff,color:#fff
    style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style D fill:#f8f9fa,stroke:#4CAF50,color:#2c3e50

CreateSlimBuilder removes unnecessary features for AOT, reducing binary size

FeatureCreateBuilderCreateSlimBuilder
appsettings.jsonYesYes
Console loggingYesYes
User secretsYesYes
HTTPS / Kestrel HTTPSYesNo (can add manually)
HTTP/3 (QUIC)YesNo (can add manually)
IIS IntegrationYesNo
EventLog / Debug loggingYesNo
UseStartup<T>YesNo
Static Web AssetsYesNo
Regex route constraintsYesNo

HTTPS isn't needed inside production containers

In Kubernetes/cloud, TLS termination is typically handled by the ingress controller (nginx, Traefik, Cloudflare Tunnel). Containers only need to listen on internal HTTP. This is why CreateSlimBuilder doesn't enable HTTPS by default — following cloud-native patterns.

5. Compatibility Matrix — What Works, What Doesn't?

FeatureFully SupportedPartially SupportedNot Supported
Minimal APIsYes
gRPCYes
JWT AuthenticationYes
CORS, HealthChecksYes
Rate Limiting, Output CachingYes
Response CompressionYes
WebSockets, Static FilesYes
SignalRYes
MVC / Razor ViewsNo
Blazor ServerNo
SessionNo
SPA hostingNo

6. JSON Source Generator — The Key to AOT

This is the most important change when moving to Native AOT. Normally, System.Text.Json uses reflection to discover object properties at runtime. Native AOT doesn't support reflection → you must use Source Generators to create serialization code at compile time.

// Declare all types that need serialization/deserialization
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(Product[]))]
[JsonSerializable(typeof(OrderRequest))]
[JsonSerializable(typeof(OrderResponse))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(PaginatedResult<Product>))]
internal partial class AppJsonSerializerContext : JsonSerializerContext { }

// Register in DI
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain
        .Insert(0, AppJsonSerializerContext.Default);
});

// Models
public record Product(int Id, string Name, decimal Price, string Category);
public record OrderRequest(int ProductId, int Quantity);
public record OrderResponse(int OrderId, string Status, DateTime CreatedAt);
public record ErrorResponse(string Code, string Message);
public record PaginatedResult<T>(T[] Items, int Total, int Page, int PageSize);

Rule: Every type in HTTP body → must have [JsonSerializable]

Including generic types. If you return PaginatedResult<Product>, you need [JsonSerializable(typeof(PaginatedResult<Product>))]. The compiler will warn if something's missing — read AOT warnings carefully when publishing.

7. Publish and Deploy

7.1 Publish for Linux x64

# Publish Native AOT for Linux
dotnet publish -c Release -r linux-x64

# Result: bin/Release/net10.0/linux-x64/publish/MyAotApi
# File size: ~15-25MB (vs ~150MB+ with runtime)
# No .NET runtime needed on target machine

7.2 Optimized Dockerfile

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src

COPY *.csproj .
RUN dotnet restore

COPY . .
RUN dotnet publish -c Release -r linux-x64 -o /app/publish

# Stage 2: Runtime — use distroless, no .NET runtime needed!
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/publish .

USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["./MyAotApi"]

runtime-deps instead of aspnet image

Since the Native AOT binary is fully self-contained, you only need a base image with native dependencies (libc, openssl...) — that's runtime-deps. The chiseled variant (Ubuntu) is even smaller: no shell, no package manager. Final container image is only ~20MB.

7.3 Container Size Comparison

ApproachBase ImageFinal Size
JIT + aspnet imagemcr.microsoft.com/dotnet/aspnet:10.0~220MB
JIT + trimmedmcr.microsoft.com/dotnet/aspnet:10.0~130MB
Native AOT + runtime-depsmcr.microsoft.com/dotnet/runtime-deps:10.0~50MB
Native AOT + chiseledruntime-deps:10.0-noble-chiseled~20MB

8. Handling Real-World Limitations

8.1 Entity Framework Core

EF Core uses extensive internal reflection. Since .NET 8+, EF Core has significantly improved AOT compatibility, but still requires source generation configuration for the model:

// Compiled Model — pre-generate EF metadata
// Run: dotnet ef dbcontext optimize
// Creates CompiledModels/ folder with pre-compiled metadata

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .UseModel(AppDbContextModel.Instance)); // Compiled model

8.2 Third-Party Libraries

Not every NuGet package is AOT-compatible. Verify by:

# Publish and read warnings
dotnet publish -c Release -r linux-x64 2>&1 | grep -i "trim\|aot"

# Each warning = a potential issue
# IL2026: Members annotated with 'RequiresUnreferencedCodeAttribute'
# IL3050: Using member with 'RequiresDynamicCodeAttribute'

Golden rule: No AOT warnings = app works identically to JIT

Microsoft guarantees: if dotnet publish emits no AOT/trimming warnings, the AOT app will behave identically to JIT. But if you ignore warnings — anything can happen. Fix or suppress intentionally.

9. Real-World Benchmarks

graph LR
    subgraph "Startup Time"
        A["JIT: 350ms"] ---|"93% reduction"| B["AOT: 25ms"]
    end
    subgraph "Memory (idle)"
        C["JIT: 120MB"] ---|"65% reduction"| D["AOT: 42MB"]
    end
    subgraph "Container Size"
        E["JIT: 210MB"] ---|"90% reduction"| F["AOT: 22MB"]
    end
    style B fill:#4CAF50,stroke:#fff,color:#fff
    style D fill:#4CAF50,stroke:#fff,color:#fff
    style F fill:#4CAF50,stroke:#fff,color:#fff
    style A fill:#e94560,stroke:#fff,color:#fff
    style C fill:#e94560,stroke:#fff,color:#fff
    style E fill:#e94560,stroke:#fff,color:#fff

Benchmark comparison between JIT and Native AOT on the same Minimal API (Todo CRUD)

What about throughput?

At steady-state (after JIT warm-up), throughput between JIT and AOT is roughly equivalent — JIT may even be slightly better thanks to Profile-Guided Optimization (PGO) at runtime. Native AOT wins on startup and footprint, not throughput. If your app is long-running and throughput matters more than startup, consider carefully.

10. Migration Path from JIT to AOT

Step 1: Add PublishAot to .csproj
Enable <PublishAot>true</PublishAot>. From here, the IDE will show AOT warnings during build. No need to publish yet, just read the warnings.
Step 2: Replace CreateBuilder with CreateSlimBuilder
Remove unnecessary features. If you need HTTPS, add it back via UseKestrelHttpsConfiguration().
Step 3: Add JSON Source Generator
Declare JsonSerializerContext with [JsonSerializable] for every type in HTTP body. This is usually the most labor-intensive step.
Step 4: Fix AOT/Trimming warnings
Run dotnet publish and address each warning. Replace reflection with source generators or alternative patterns.
Step 5: Comprehensive testing
Run integration tests on the AOT binary. Pay special attention to edge cases around serialization, DI resolution, and middleware.
Step 6: Benchmark and deploy
Compare startup time, memory, throughput between JIT and AOT. Deploy AOT binary in multi-stage Dockerfile with chiseled base image.

Conclusion

Native AOT isn't a "silver bullet" for every .NET application. It has clear trade-offs: slower builds (60s+ vs 5s), no MVC/Blazor Server support, and discipline required with source generators. But for the right use cases — serverless functions, scale-from-zero microservices, CLI tools, edge computing — it's a game changer: 10x faster startup, 3-5x lighter footprint, no runtime dependency.

Start with the webapiaot template, fix all warnings, benchmark and compare. Native AOT in .NET 10 is production-ready for Minimal APIs and gRPC — the two most popular patterns in modern microservices architecture.

References