Native AOT in .NET — Ahead-of-Time Compilation for 10x Faster Startup
Posted on: 4/26/2026 5:18:15 PM
Table of contents
- 1. What is Native AOT?
- 2. When Should (and Shouldn't) You Use Native AOT?
- 3. Getting Started with Native AOT in ASP.NET Core
- 4. CreateSlimBuilder vs CreateBuilder
- 5. Compatibility Matrix — What Works, What Doesn't?
- 6. JSON Source Generator — The Key to AOT
- 7. Publish and Deploy
- 8. Handling Real-World Limitations
- 9. Real-World Benchmarks
- 10. Migration Path from JIT to AOT
- Conclusion
- References
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.
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?
| Scenario | Native AOT | Traditional JIT |
|---|---|---|
| Serverless / AWS Lambda / Azure Functions | Ideal — ultra-fast cold start | Slow cold start, higher cost |
| Kubernetes scale-from-zero | Pod ready in milliseconds | Pod needs hundreds of ms |
| CLI tools / Lightweight microservices | Small binary, runs instantly | Requires runtime on target |
| IoT / Edge computing | Low RAM, small disk | Too heavy for constrained devices |
| MVC + Razor Views | Not supported | Only option |
| Heavy Reflection usage | Must refactor or use source gen | Works normally |
| Plugin system / dynamic loading | Not feasible | Fully 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
| Feature | CreateBuilder | CreateSlimBuilder |
|---|---|---|
| appsettings.json | Yes | Yes |
| Console logging | Yes | Yes |
| User secrets | Yes | Yes |
| HTTPS / Kestrel HTTPS | Yes | No (can add manually) |
| HTTP/3 (QUIC) | Yes | No (can add manually) |
| IIS Integration | Yes | No |
| EventLog / Debug logging | Yes | No |
| UseStartup<T> | Yes | No |
| Static Web Assets | Yes | No |
| Regex route constraints | Yes | No |
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?
| Feature | Fully Supported | Partially Supported | Not Supported |
|---|---|---|---|
| Minimal APIs | Yes | ||
| gRPC | Yes | ||
| JWT Authentication | Yes | ||
| CORS, HealthChecks | Yes | ||
| Rate Limiting, Output Caching | Yes | ||
| Response Compression | Yes | ||
| WebSockets, Static Files | Yes | ||
| SignalR | Yes | ||
| MVC / Razor Views | No | ||
| Blazor Server | No | ||
| Session | No | ||
| SPA hosting | No |
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
| Approach | Base Image | Final Size |
|---|---|---|
| JIT + aspnet image | mcr.microsoft.com/dotnet/aspnet:10.0 | ~220MB |
| JIT + trimmed | mcr.microsoft.com/dotnet/aspnet:10.0 | ~130MB |
| Native AOT + runtime-deps | mcr.microsoft.com/dotnet/runtime-deps:10.0 | ~50MB |
| Native AOT + chiseled | runtime-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
<PublishAot>true</PublishAot>. From here, the IDE will show AOT warnings during build. No need to publish yet, just read the warnings.UseKestrelHttpsConfiguration().JsonSerializerContext with [JsonSerializable] for every type in HTTP body. This is usually the most labor-intensive step.dotnet publish and address each warning. Replace reflection with source generators or alternative patterns.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
Amazon Aurora DSQL — Distributed Serverless SQL for Multi-Region Architecture
A2A Protocol v1.2 — The Standard for AI Agent-to-Agent Communication
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.