Native AOT for ASP.NET Core APIs in .NET 10 — When Cold-Start Latency Actually Matters
Native AOT trades some flexibility for a 50ms cold start and a 30MB image. Worth it for serverless and edge. Not worth it for the average internal API.
Cold start is a feature. If you're running on AWS Lambda, Cloudflare Workers (well, .NET there is still rough), or any per-tenant container scheme, the difference between "1.2 second JIT warm-up" and "50 millisecond startup" is the difference between users waiting and users not noticing. That's the case for Native AOT.
The pitch: AOT compiles your .NET code to a native binary at publish time. No JIT, no reflection-heavy fallback paths, no extra warm-up. The cost is that anything reflection-based has to be either source-generated or banned.
Publishing a Minimal API as AOT:
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
JSON needs a source-generated context (no runtime reflection):
[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(List<User>))]
public partial class AppJsonContext : JsonSerializerContext;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
Real numbers I measured on a small /users/{id} API:
| Build | Cold start | RSS at idle | Image size |
|---|---|---|---|
| JIT (CoreCLR) | 1.1s | 110 MB | 220 MB |
| Native AOT | 48 ms | 28 MB | 35 MB |
The cold start delta is the headline. The RSS delta is the unsung win — you can pack 4x more containers on the same node.
What breaks:
- EF Core: works in .NET 10 but you need to use compiled queries and pre-generate the model. It's a real upgrade in 10, but expect to read the migration docs. Pre-10 EF Core + AOT was painful.
- AutoMapper, Newtonsoft.Json, anything reflection-heavy: blocked. Use
System.Text.Jsonwith source generators. Use manual mapping or Mapperly. - Some logging providers: check for AOT support before adopting. Serilog mostly works; older sinks might not.
- Dependency injection of open generics with complex constraints: occasionally trips the trimmer. The warnings are real, read them.
When to reach for AOT:
- Serverless functions where cold start matters and you can't keep warm.
- Per-tenant container schemes (one container per customer, density matters).
- Edge runtimes where startup is the latency budget.
- CLIs and tools where 1-second startup feels broken.
When to ignore it:
- Long-running APIs behind a load balancer where you pay the warm-up once. The cold start savings disappear into the noise of normal request handling.
- Anything heavily reliant on third-party libraries you don't control.
- Teams without bandwidth to chase trimmer warnings as the codebase grows.
The realistic adoption pattern: ship a non-AOT version first, get it to production, then publish-test with AOT once a quarter to see if you can flip the switch. Don't make the AOT version the first version. Too many migrations at once.