A .NET process that starts the classic way does a fair amount of work before it answers its first request: loading assemblies, reading metadata, resolving types, then compiling method after method to native code just in time (JIT), as they're first called. On a long-running app, none of that is noticeable. On a Lambda function that spins up a fresh container per invocation, or a Kubernetes pod that needs to be ready within seconds under a traffic spike, that ramp-up is exactly what matters.
And as of August 1, 2025, it's no longer just a latency problem. That's the day AWS changed its billing model: the init phase of zip-packaged Lambda functions using managed runtimes is now billed the same way as regular execution time. Previously, that only applied to custom runtimes, Provisioned Concurrency, or container image packaging. A slow cold start now costs literal money, not just perceived wait time.
That's exactly where Native AOT comes in. Let's look at what it actually does, where it pays off, where it flat-out doesn't work, and what that means for your cloud bill.
What Native AOT actually does
In the regular .NET model, dotnet publish compiles your code to intermediate language (IL). At startup, the runtime loads that IL, and the JIT compiler translates each method to native machine code the first time it's called. That's why .NET apps are so flexible: reflection, dynamic assembly loading, runtime code generation, all of it works because the runtime knows what's in your code right up to the last moment.
Native AOT flips that around. With <PublishAot>true</PublishAot> in your project and dotnet publish -r <RID>, publishing runs an IL compiler (ILC) that translates your entire app, including every referenced library, to native code ahead of time. The result is a single, platform-specific executable with a stripped-down runtime (including the garbage collector) embedded inside it. No JIT runs at runtime, because there isn't one anymore.
For that to work, everything has to be statically knowable at publish time. Unused code is removed via trimming, and anything that would decide at runtime what code even exists is off the table. That's not an implementation detail, it's the central constraint almost everything else in this article revolves around.
Faster, but not better in every way
Microsoft's documentation is deliberately vague on hard numbers, because the actual gains depend heavily on the workload, but it consistently calls out three effects: smaller deployment size, shorter startup time, lower memory demand. In practice, figures like "roughly three times faster startup at less than half the memory" circulate widely, and individual teams have published concrete before/after numbers such as going from around 70ms to roughly 14ms for a simple API. Treat numbers like that as a direction, not a guarantee for your app: they depend heavily on how much your app actually loads and initializes on startup.
The flip side matters just as much: Native AOT optimizes for startup, not automatically for throughput. The JIT can optimize based on actual runtime behavior (tiered compilation, PGO); Native AOT locks the code in at publish time. One concrete detail that shows this: System.Linq.Expressions always runs in interpreted mode under Native AOT, noticeably slower than the runtime-compiled code you'd get under JIT. For a short-lived function, that's irrelevant. For a long-running, high-throughput service, it can be the exact opposite of what you want.
When Native AOT pays off
- Serverless functions. AWS Lambda (custom runtime running a Native AOT binary) and Azure Functions (isolated worker with AOT support) benefit most directly, because every cold start counts individually there, and as of August 2025, financially too.
- Kubernetes with aggressive autoscaling or scale-to-zero. KEDA, Knative, or a custom HPA setup that spins up pods from nothing benefit from a short time-to-ready in the same way serverless functions do.
- CLI tools and sidecars. A command-line tool that runs a hundred times a day for a fraction of a second feels every millisecond of saved startup time directly, no cloud context required.
- High-density deployments. Many small service instances sharing infrastructure, where a lower per-instance memory footprint directly means more instances per node.
- Platforms where JIT simply isn't allowed. On iOS and tvOS, the operating system prohibits just-in-time compilation outright. There, ahead-of-time compilation isn't one option among several, it's the only way to run a .NET app at all.
The cost math: what the faster start actually saves
Let's work through an example with clearly stated assumptions, not universal numbers.
Lambda, calculated directly. Take a function with 512MB of memory, 500,000 invocations a month, and a cold-start rate of 30% (150,000 cold starts), realistic for a moderately used backend. If the init phase takes an average of 600ms on a classic JIT-based runtime versus 50ms on a Native AOT binary, you save 0.5GB × 0.55s = 0.275 GB-seconds per cold start. At roughly $0.0000167 per GB-second, that adds up to just about $0.69 a month across 150,000 cold starts. Purely from the cold-start-duration saving, that's a footnote, not a business case.
The real lever is elsewhere. Provisioned Concurrency bills for reserved capacity around the clock, whether the function is invoked or not. If Native AOT makes your cold starts consistent enough that you can drop Provisioned Concurrency entirely, you eliminate a standing fixed cost, not a per-invocation rounding error. And because Lambda bills memory × time, a smaller memory footprint affects every invocation, not just cold ones. If Native AOT lets you drop the configured memory from 512MB to 256MB, that factor is halved on every single execution, not just the occasional cold start.
Kubernetes, by memory density. If a classically started .NET web API process needs around 300MB of RSS in steady state and a Native AOT build needs only around 120MB, a node with 4,096MB of allocatable memory fits roughly 13 pods in one case and 34 in the other. That's not an abstract performance win, it's the difference between three nodes and one node for the same load.
The trade-off belongs in the math too. For a long-running service with few restarts, steady load, and autoscaling that rarely triggers anyway, the startup advantage is practically irrelevant. There, steady-state throughput is what matters, and Native AOT is, at best, neutral. The migration only pays off there if memory density alone makes a difference.
Where it simply doesn't work
Some of the limitations aren't bugs, they're the direct consequence of requiring everything to be statically known at publish time:
- No
Assembly.LoadFile/Assembly.LoadFrom. Plugin architectures that load unknown assemblies at runtime don't work, because trimming removes anything that isn't statically reachable. - No
System.Reflection.Emit. Runtime code generation is off the table. That rules out older DI containers, mocking frameworks, and ORMs that generate dynamic proxies at runtime. - No C++/CLI, no built-in COM (Windows). Classic COM interop applications stay on the JIT model unless they're migrated to source-generated
ComWrappers. - EF Core's Native AOT support is experimental. Since EF Core 9, there's an MSBuild task for precompiled models and queries, but per Microsoft it's "not yet suited for production use." A database-heavy service built on EF Core isn't a good Native AOT candidate today.
- Large parts of ASP.NET Core are missing. The table below shows a slice of the official compatibility list:
| Feature | Native AOT |
|---|---|
| Minimal APIs | ✅ (partial) |
| gRPC | ✅ |
| JWT authentication | ✅ |
| MVC (controllers with views) | ❌ |
| Blazor Server | ❌ |
| Session | ❌ |
| Other authentication (e.g. cookie-based, external OAuth providers) | ❌ |
| SignalR | ✅ (partial) |
A classic server-rendered web app with Razor views, sessions, and cookie-based login is, right now, simply not a Native AOT candidate, no matter how appealing the faster startup sounds.
- The NuGet ecosystem is uneven. Many popular libraries use reflection for type discovery, load dependencies conditionally at runtime, or generate code dynamically. .NET 10 introduced the
IsAotCompatibleattribute to mark libraries explicitly, but far from every library carries it yet. Check your dependencies before planning a migration, not after. - No more "build once, run anywhere." Every target platform needs its own publish run (
win-x64,linux-arm64,osx-arm64, …), and a Linux binary built on Ubuntu 20.04 runs on 20.04 and later, but not on older versions. That's a meaningful jump in CI matrix complexity compared to a single framework-dependent build.
Setup: from PublishAot to a runnable artifact
Don't migrate the monolith in one go. Pick a single Lambda function or a single minimal-API or gRPC service and work through the full chain from project file to Docker image.
Project and code
Enable Native AOT in the project:
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
</PropertyGroup>
InvariantGlobalization is optional, but it saves further size and startup time if you don't need culture-specific sorting or formatting, verify that against real data, not just numbers.
In Program.cs, you need CreateSlimBuilder instead of CreateBuilder, and a source-generated JSON context for every class transmitted over HTTP:
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new HealthStatus("ok")));
await app.RunAsync();
record HealthStatus(string Status);
[JsonSerializable(typeof(HealthStatus))]
partial class AppJsonContext : JsonSerializerContext;
Publishing targets a specific platform, not "any CPU":
dotnet publish -c Release -r linux-x64 --self-contained
Read every warning the command emits (IL2xxx/IL3xxx codes). A warning you ignore can turn into a runtime exception, not a fallback with slightly reduced functionality.
Docker image
For a Kamal-style deployment, you need a multi-stage build: the build stage needs the full SDK container plus a compiler toolchain (clang, zlib1g-dev); the final stage needs only the executable:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN apt-get update && apt-get install -y clang zlib1g-dev \
&& dotnet publish -c Release -r linux-x64 --self-contained -o /app
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["./MyService"]
The final image no longer contains a .NET runtime at all, just the native binary and its OS-level dependencies, correspondingly smaller than a classic self-contained deployment.
Special case: AWS Lambda custom runtime
For Lambda, you additionally need Amazon.Lambda.RuntimeSupport and Amazon.Lambda.Core:
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
await LambdaBootstrapBuilder.Create(Handler, new SourceGeneratorLambdaJsonSerializer<AppJsonContext>())
.Build()
.RunAsync();
static OrderResult Handler(OrderRequest request) => new(request.OrderId, "processed");
Important: for a custom runtime, AWS expects the native binary in your deployment package to be named exactly bootstrap. Publish normally, and the file is named after your project, not bootstrap, and Lambda finds nothing to execute. Rename it during publish, or set <AssemblyName>bootstrap</AssemblyName> in the project.
Stumbling blocks in practice
dotnet buildalmost never shows warnings;dotnet publishdoes. The full trimming and AOT analysis only runs at publish time. If your PR pipeline only runsdotnet build, you won't notice a broken dependency until the actual deploy, in the worst case in production. Adddotnet publishto the PR pipeline, not just the deploy job.- A missing
JsonSerializerContextonly surfaces at runtime. Without a source-generated context,System.Text.Jsonfalls back to reflection, which doesn't work under Native AOT. The failure doesn't show up at publish time, it shows up as an exception on the first real serialization attempt, typically on the first real request. - Reflection-based DI auto-registration returns empty results instead of an error. Libraries like Scrutor scan assemblies at runtime for types matching a registration pattern. Under Native AOT, whatever could have been scanned has already been trimmed away, the result is a service container that silently finds nothing at resolution time instead of failing loudly at startup. Replace auto-registration with explicit
AddSingleton<TInterface, TImplementation>()calls using concrete types. - A binary built on Ubuntu doesn't run on Alpine. Alpine uses musl instead of glibc as its C library. If you build with
-r linux-musl-x64on a glibc-based SDK image, the result still links the glibc interpreter, and the container won't start. Build for Alpine targets using an Alpine-based SDK image (mcr.microsoft.com/dotnet/sdk:10.0-alpine), not-r linux-musl-x64on a Debian image. - The custom runtime binary isn't named
bootstrap. See above, an easy detail to miss and a fully blocking deployment failure on AWS Lambda.
Then measure in your own environment, not against community benchmarks. A cold start in your Lambda function with your actual dependencies can behave very differently from the hello-world example in the docs.
Conclusion
Native AOT solves a very specific problem: startup time and memory footprint that translate directly into money in serverless and highly elastic cloud environments, and since August 2025, literally so on AWS Lambda. The direct effect from the cold-start-duration saving alone is often smaller than expected; the real lever is what Native AOT lets you stop paying for: Provisioned Concurrency, oversized memory allocations, idle buffer replicas.
At the same time, the list of what doesn't work is long enough to take seriously: no Reflection.Emit, no dynamic assembly loading, no MVC with views, EF Core still experimental, a NuGet ecosystem that's still catching up. For a lean, JSON-based service running in a serverless or highly elastic environment, Native AOT is a mature option today. For a classic, EF Core-backed web app with sessions and views, it isn't yet, and probably won't be for a while.
