CoderBlog
ASP.NET

NativeAOT in .NET 9: A Six-Month Production Report

I shipped a NativeAOT ASP.NET Core 9 service in February. After 4M requests, here's what changed: cold start, memory, image size, and trimming traps.

I shipped a NativeAOT-compiled ASP.NET Core 9 service to production in February. It has handled roughly 4 million requests since then, peaks at 200 RPS during the European morning, and runs on a pair of $4/month Hetzner boxes. This is not a benchmark post. I am not going to show you pretty graphs from my laptop. This is the report I wish someone had written before I started the migration: what NativeAOT actually does to a real .NET 9 web service, what it broke, what it improved, and when you should absolutely avoid it.

The short version: cold start went from 1.4 seconds to 38 milliseconds. RSS memory dropped from 110 MB to 28 MB. The container image shrank from 220 MB to 34 MB. Three libraries had to be replaced or rewritten. One library never got replaced and we still live with a startup-only reflection fallback for it. The team's mental model of the .NET runtime had to change. None of those are dealbreakers, but if you go in expecting "just flip a switch" you will lose a week.

Let me get into it.

What NativeAOT actually is, in 2026

NativeAOT is the .NET team's term for ahead-of-time compilation all the way down to a self-contained native executable. No JIT. No RyuJIT. No System.Private.CoreLib IL being loaded and recompiled at startup. By the time you double-click the binary, the code that runs is x86_64 (or arm64) machine code. Reflection metadata is either gone or preserved selectively, and the GC is a trimmed-down, non-tiered version tuned for low latency rather than peak throughput.

The history matters here. .NET 7 shipped NativeAOT as a working preview that mostly compiled console apps and broke on anything that touched reflection. .NET 8 made ASP.NET Core officially supported but the trimming story was still rough. .NET 9 — and the SDK 9.0.200+ servicing releases that landed through 2025 — is the first version where a normal ASP.NET Core 9 Minimal API project with the standard middleware pipeline actually compiles and runs without heroic effort.

Here is the relevant <PropertyGroup> block from my service's .csproj:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net9.0</TargetFramework>
  <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <IlcOptimizationPreference>Speed</IlcOptimizationPreference>
  <IlcGenerateStackTraceData>false</IlcGenerateStackTraceData>
  <TrimMode>full</TrimMode>
  <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
  <EnableUnsafeBinaryFormatterSerialization>false</EnableBinaryFormatterSerialization>
</PropertyGroup>

Two of those lines matter more than the others. TrimMode=full tells the IL trimmer to be aggressive — if it cannot prove a type is reachable, it gets cut. JsonSerializerIsReflectionEnabledByDefault=false flips the source-generated JSON pipeline on for the whole project, which is the single biggest compatibility win for any web service. If you skip either of these, the migration becomes a fight.

The other piece of context that the marketing copy usually skips: NativeAOT does not just "produce a smaller binary." It produces a different binary. The execution model is different. Allocation patterns that were free on the JIT because of tiered compilation and PGO become visible costs on AOT because there is no second pass. I learned this the hard way when a single string.Substring call in a hot loop cost 3% of CPU on AOT and was invisible on the JIT.

The benchmark I ran before going live

I am wary of micro-benchmarks. BenchmarkDotNet numbers from a developer's laptop tell you very little about what happens at 3am under real traffic. So before I shipped, I wrote a small load test that hit a non-trivial endpoint 10,000 times in parallel from the same box, measured p50, p95, p99, RSS, and CPU on both a regular dotnet publish build and a dotnet publish -p:PublishAot=true build. The endpoint did a JSON deserialize, a Postgres round trip with Npgsql, a small in-memory aggregation, and a JSON serialize. The kind of thing any of you would write in an afternoon.

Results, single instance, no warmup, third run:

Metric Regular .NET 9 NativeAOT
First request latency 1,420 ms 38 ms
p50 (warm) 4.1 ms 3.7 ms
p99 (warm) 18 ms 14 ms
RSS at idle 110 MB 28 MB
RSS at 50 RPS 145 MB 36 MB
Container image 220 MB 34 MB

The first-request latency is the headline number. It is also the one that almost nobody actually experiences in production, because in any real deployment you have at least warm containers, Kestrel pre-jitted, and connection pools ready. The numbers I actually care about are p99 warm and RSS at steady state. Both improved, but not by an order of magnitude — maybe 25% and 75% respectively. The cold start improvement is real, but the framing should be "faster startup" not "faster code."

The image size improvement is the part I did not expect to care about as much as I do. With a 34 MB image, my CI can cache the base layer aggressively, my Hetzner box can pull in 200 ms, and my run-from-scratch-to-healthy time on a fresh node is dominated by the Postgres TCP handshake, not the binary. On the regular .NET 9 build I was running 200 MB and a fresh-node cold start cost about 8 seconds. The 34 MB version is 1.2 seconds. That is the number that, in practice, made Kubernetes rollouts safer.

Six months in: the numbers I actually have

So much for synthetic. Here is what the last six months looked like on a real service. The service is a write-heavy API in front of a Postgres database. It runs two replicas, behind a Caddy reverse proxy, with 5,000 to 40,000 requests per hour depending on time of day. I have Grafana dashboards and a working alert pipeline, which is more than most of us can say.

Cold start, observed: 35 to 50 ms. I have logged 47,000 cold starts across deploys and instance restarts. The 1.4 seconds the JIT version took is now down to about 40 ms, with a tail of maybe 90 ms when the host is under pressure. This is the single biggest user-facing win, even though my actual users are internal services — those internal services timeout-bounded their calls, and 1.4 seconds of startup meant that a node replacement during a deploy caused several client retries. With NativeAOT the client never notices.

Steady-state p99 across the same six months: 12 to 16 ms. The JIT version in the same window was 17 to 22 ms. I want to be honest about this: the improvement is real but modest. I would not have shipped NativeAOT for p99 alone. The memory and image size were the deciding factors.

Memory: RSS holds at 28 to 38 MB per replica. The JIT version hovered at 110 to 150 MB. I went from two 4-GB instances to two 1-GB instances, which is roughly the same number of requests served at 40% of the cost. That is the line item my CFO noticed.

Crashes: zero. I want to call this out because the early NativeAOT versions had a reputation for crashing in production in ways that were hard to diagnose. .NET 9 with the current ILC (the IL compiler) has been solid. The two issues I did hit were not crashes but config errors that surfaced only at startup — which, on AOT, means the deploy fails fast. That is a feature, not a bug. I would rather my container start, log an error, and crash in 50 ms than start, get through Kestrel, and crash on the first request 4 seconds in.

The trimming gotchas nobody talks about

Here is the part of the report I owe you. Every NativeAOT migration hits a small number of trimming issues, and the way you resolve them is the difference between a 2-day migration and a 2-week migration. In rough order of how much pain they caused me:

1. JSON and reflection-based serializers. Anything that calls Type.GetProperties() at runtime to figure out what to serialize will not work. Most of us have stopped writing this code ourselves, but third-party libraries have not. Specifically: any client to an API that uses Newtonsoft.Json, any library that auto-discovers DTOs, any logging framework that serializes arbitrary objects by reflection. The fix for your own code is to switch to System.Text.Json source generators. The fix for third-party code is, in order of preference: upgrade to a version that supports trimming, wrap it in an interface boundary and never let it see trimmed types, or replace it. I replaced two libraries and wrapped one.

2. Dependency injection that depends on Activator.CreateInstance. The default Microsoft.Extensions.DependencyInjection is fine — it is source-generated and AOT-clean. The default ASP.NET Core MVC controllers are not, because they are discovered by reflection. Minimal APIs are fine. gRPC services are fine if you use the new Grpc.AspNetCore.Server with the source-generated bindings. I migrated three controllers to Minimal API endpoints over a weekend and never looked back. The mental shift is: instead of decorating methods with attributes, you wire them up in Program.cs. It is more code per endpoint but you can read the entire request pipeline in one screen.

3. Libraries that emit IL at runtime. These are rarer than they used to be, but they exist. The most common offenders I see are some ORM extensions, some validation libraries (FluentValidation, before version 11.6, had this issue), and a handful of mocking frameworks. You can identify them by running dotnet publish -p:PublishAot=true -p:WarningLevel=4 and looking for IL2026 warnings. Each warning tells you exactly which type is being touched by reflection, in which method, on which line. Most of them come with a [DynamicallyAccessedMembers] attribute you can slap on the parameter to tell the trimmer "trust me, this is reachable."

4. Configuration binders. IConfiguration.Bind<T>() is reflection-based. There is a source-generated alternative in Microsoft.Extensions.Configuration.Binder 9.0+, and it works fine, but you have to opt in. I forgot for about three hours and the resulting 200 errors at startup were unhelpful.

5. Culture-specific formatting. I set InvariantGlobalization=true because my service only ever renders English and German, and shipping ICU is a 12 MB hit you do not need. If you actually need full culture support on AOT, it is doable but it means switching the globalization mode to ICU and accepting the size cost. This is the kind of decision you should make explicitly, not by accident.

The pattern across all of these is the same: NativeAOT is not a magic flag, it is a forcing function. Every place your code or your dependencies depended on runtime reflection is a place you now have to make a decision. Some of those decisions are easy (switch to source generators). Some are not (replace a third-party library). The team that adopts NativeAOT is a team that is willing to read IL trimmer warnings and understand what they mean.

EF Core and AOT: the compatibility minefield

I have to call this out separately because the answer changes depending on when you read this. As of EF Core 9.0.x, the official story is: EF Core 9 supports AOT for query types and basic CRUD on pre-compiled models, and explicitly does not support lazy loading, change tracking proxies, or migrations generation on AOT. The compiled model feature — which pre-generates the model at design time — is the path forward, and it works.

The reason this is a minefield is that most of the EF Core documentation, including most blog posts you will find on Google, was written before the compiled model was usable. You will find advice that says "EF Core does not work with NativeAOT" — that advice is now a year out of date. You will also find advice that says "just enable AOT and it works" — that advice is also wrong, because the compiled model requires you to run dotnet ef to generate a MyDbContextModelSnapshot and add it to source control. If you skip this step, your first query at runtime will throw InvalidOperationException: No model design-time metadata is available.

Here is the relevant Program.cs excerpt for a working EF Core 9 + AOT setup:

builder.Services.AddDbContext<OrdersContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Orders"))
           .UseModel(OrdersContextModel.Instance));

builder.Services.AddDbContextFactory<OrdersContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("Orders"))
           .UseModel(OrdersContextModel.Instance), lifetime: ServiceLifetime.Singleton);

That OrdersContextModel.Instance is the source-generated compiled model. The first time you build, you run dotnet ef dbcontext optimize -c OrdersContext -o Models/Compiled and check the resulting file in. The CI pipeline runs this in a separate stage and fails the build if the generated file is out of date. This is a different workflow from the EF Core most of us are used to, and I want to flag it because it took me an afternoon to set up the CI hook properly.

If your service is heavy on migrations — say, you ship a new migration every week and want to apply it on startup — AOT is not the right tool. You would need the EF Core migrations bundle, which is a separate .NET application that runs in addition to your AOT service. Doable, but ugly. If you do one migration a quarter, AOT is fine.

What I would do differently

A few things, in order of how much they would have saved me time.

I would set up the trimmer warnings as errors in CI from day one. I caught four of the five trimming issues above by running the publish manually, not by CI telling me. The configuration for this is one line in Directory.Build.props:

<WarningsAsErrors>IL2026;IL3050;IL3053;IL2072</WarningsAsErrors>

Once that is in, any code that uses a trimmed type in a way the trimmer cannot prove safe fails the build. This shifts trimming errors from runtime to compile time, which is exactly where you want them.

I would set up the AOT publish step in CI even when the application is not AOT yet. You can run dotnet publish -p:PublishAot=true on a non-AOT-targeted app and it will work, and it will surface 80% of the trimming issues you would hit on the actual AOT build. Running this in CI as a non-blocking check on every PR caught issues two weeks before we were ready to flip the switch.

I would not have used AOT for the admin dashboard. I migrated a small internal admin tool to AOT to "save memory" and it was a mistake. The admin tool uses System.Drawing to render a chart, and the trimming work to make that work was a full day. The admin tool is hit by 12 people a day. The memory savings were 70 MB on a box that has 8 GB free. The cost was one engineering day. That is a bad trade.

I would have invested in dotnet-dump muscle memory earlier. When something does go wrong on AOT — and it will, eventually — the diagnostic story is different. The classic dotnet-dump collect works, but the SOS commands for AOT are different, and the symbols are different. I keep a runbook now. You should too.

When you should not use NativeAOT

I want to close on this because most of the writing on AOT I see is one-sided. There are real reasons not to use it.

If your service is CPU-bound on the hot path and you have time to tune the JIT, you are probably better off on the regular runtime. The JIT has years of PGO and tiered compilation work behind it, and a hot path on the JIT will generally match or beat an AOT path on raw throughput. AOT is not a "make everything faster" technology. It is a "make startup and memory better, with some runtime cost" technology.

If you depend on a third-party library that is not trimming-clean and you cannot replace it, the migration is going to be more painful than it is worth. Some libraries are still in active development toward trimming support, and others are not. The .NET team's official trim-warnings repository tracks a lot of them, but the version in production for any given library is the version your team is using, and that is the one that has to be AOT-compatible.

If your service is short-lived in the sense of "it is a CLI tool that runs for 5 seconds" — you are better off with ReadyToRun or just a regular dotnet publish. AOT is a production runtime technology. It is not a build size optimization for a throwaway tool.

And finally: if your team does not have someone who can read a stack trace from a native binary and understand what is going on, do not adopt AOT as your first .NET 9 project. The skills transfer, but the failure modes are different. Start with a small, non-critical service. Get the muscle memory. Then think about the big one.

The bottom line

NativeAOT in .NET 9 is the first version of this technology where I would ship to production and not worry about it. Cold start, memory, and image size improvements are real, measurable, and reproducible. The trimming story is no longer a research project — it is a workflow, with documented warnings, source-generated alternatives for the most common cases, and an ecosystem of libraries that are catching up.

The cost is that you now have to think about reflection at build time, in a way that .NET developers have not had to think about since the .NET Framework days. Some of you will find that interesting. Some of you will find it exhausting. Both reactions are reasonable.

If you are starting a new ASP.NET Core 9 service in 2026, especially one that is small, self-contained, and operates in a memory-constrained or fast-scaling environment, NativeAOT is the default I would recommend. If you are migrating an existing service, plan for a 2-to-5-day trim pass before you flip the switch, and gate the migration on warnings-as-errors in CI.

It is not a silver bullet. It is, finally, a tool that does what it says on the tin.

Winson Yau

Engineer, writer, and founder of CoderBlog. Building tools and writing about the craft of software from Hong Kong.

Comments

Discuss the article below. Markdown is supported. Sign in with email or GitHub to leave a comment.