.NET applications have traditionally depended on the .NET runtime and JIT compiler to execute managed code. This model works well for a wide range of applications, but it is not the only deployment option available.
Native AOT (Ahead-of-Time) compilation takes a different approach. Instead of relying on JIT compilation when the application starts, the application is compiled ahead of time into native code for the target platform.
This can change several characteristics of an application, including startup behavior, deployment requirements, memory usage, and compatibility with some .NET features.
Native AOT is particularly interesting for applications such as command-line tools, small services, containerized applications, and workloads where startup time is important.
However, Native AOT is not automatically faster for every application.
The right way to evaluate it is to build the same application using both deployment models and measure the things that matter for your workload.
This article explains how Native AOT works, how to enable it, what to benchmark, and what limitations developers should consider.
What Is Native AOT?
With a traditional .NET deployment, the application contains managed assemblies.
A simplified execution flow looks like this:
C# Source
|
v
IL Assemblies
|
v
.NET Runtime
|
v
JIT
|
v
Native Machine Code
Native AOT changes the model:
C# Source
|
v
IL Assemblies
|
v
Native AOT Compiler
|
v
Native Executable
|
v
Operating System
Much of the compilation work happens before the application is deployed.
The resulting executable is native code for a specific target platform and architecture.
This means Native AOT is not simply a switch that makes a normal .NET application run faster.
It changes the deployment and execution model.
Why Developers Use Native AOT
Native AOT can be useful when an application needs characteristics such as:
Fast startup
Smaller runtime dependency requirements
Self-contained native deployment
Reduced JIT work at startup
Predictable deployment artifacts
Useful behavior for short-lived processes
Consider a command-line utility:
mytool --input data.json
If the tool runs for a few hundred milliseconds, startup overhead can represent a meaningful part of its total execution time.
For a long-running web service that runs continuously for days, startup may matter much less.
This is why workload type is important.
Native AOT vs JIT Deployment
The two approaches have different characteristics.
Area | Traditional JIT | Native AOT |
|---|---|---|
Compilation | At runtime | Before deployment |
Startup work | Includes JIT work | Less JIT work |
Target-specific binary | Runtime generates code for target | Native binary built for target |
Dynamic code | Broad support | More restricted |
Reflection | Broad support | Requires additional consideration |
Deployment | Managed assemblies and runtime | Native executable |
Build process | Generally simpler | More involved |
Best fit | General-purpose applications | Suitable workloads with AOT-compatible dependencies |
Native AOT is therefore a deployment choice, not simply a performance setting.
Enabling Native AOT
For a suitable .NET application, Native AOT can be enabled in the project file.
For example:
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
<PublishAot>true</PublishAot>
</PropertyGroup>
You can then publish the application for a target runtime.
For example:
dotnet publish -c Release -r linux-x64
The runtime identifier should match the environment where the application will run.
For an Arm64 Linux environment, the target would need to be the appropriate Arm64 runtime identifier instead.
The important point is that Native AOT produces a native binary for a specific target.
A Simple Console Application
Start with a small application:
using System.Diagnostics;
Console.WriteLine("Application started.");
var stopwatch = Stopwatch.StartNew();
long total = 0;
for (int i = 0; i < 10_000_000; i++)
{
total += i;
}
stopwatch.Stop();
Console.WriteLine($"Total: {total}");
Console.WriteLine($"Work time: {stopwatch.ElapsedMilliseconds} ms");
This is useful for experimenting, but it is not enough to conclude that Native AOT is faster.
The test only measures one small piece of work.
A useful evaluation needs to measure startup, execution, memory, and deployment characteristics separately.
Startup Time
Startup is one of the areas where Native AOT can be particularly relevant.
A traditional application may perform several runtime initialization tasks before reaching the main application code.
A Native AOT application has already been compiled to native code.
To test startup, run the application as a separate process and measure the time from process launch to a known output or completion point.
Avoid using:
Stopwatch.StartNew();
inside the application to measure total startup.
That stopwatch starts after the process has already started.
Instead, use an external measurement mechanism or a suitable benchmarking tool.
For example, on Linux, a basic shell-level measurement can be used:
time ./myapp
This provides process-level timing information.
Repeat the test multiple times and compare the same application under both deployment models.
Cold Start vs Warm Start
Startup tests should distinguish between different conditions.
Cold Start
The application is started when relevant code and files are not already benefiting from the operating system's caches.
Warm Start
The application is launched after the operating system has already cached relevant files.
These scenarios can produce different results.
For applications such as serverless functions and short-lived CLI tools, cold-start behavior may be particularly important.
For long-running services, startup may have a much smaller impact on overall performance.
Measuring Memory Usage
Memory usage is another important measurement.
Do not simply compare executable file sizes and assume that the smaller file consumes less memory at runtime.
Measure the running process.
A practical test should record:
Initial memory
Steady-state memory
Peak memory
Allocation behavior
Garbage collection activity
For a long-running service, steady-state behavior may matter more than startup memory.
For a short-lived process, startup and peak memory can be more relevant.
Throughput Testing
Native AOT should also be tested against the actual workload.
For example, imagine a service processing messages:
public static int Process(int value)
{
return value * 2;
}
A benchmark might process many values:
public static long ProcessValues(int[] values)
{
long total = 0;
for (int i = 0; i < values.Length; i++)
{
total += Process(values[i]);
}
return total;
}
Now compare the same application deployed using normal JIT execution and Native AOT.
The important question is not whether one test is faster.
The question is whether the difference remains meaningful under the application's real workload.
Native AOT Does Not Mean No Runtime
Native AOT applications are native executables, but they still rely on runtime support and libraries.
The important distinction is that application code is compiled ahead of time instead of being JIT-compiled in the normal way at runtime.
Native AOT also has a different set of supported features and restrictions.
This is particularly important for applications that depend heavily on runtime code generation or unrestricted reflection.
Reflection Requires Attention
Reflection is widely used in .NET applications.
For example:
Type type = typeof(Customer);
PropertyInfo[] properties =
type.GetProperties();
Applications that dynamically discover types, members, or assemblies may require additional work when using Native AOT.
Native AOT relies heavily on knowing what code is required at build time.
If the compiler cannot determine that a type or member is required, the application may need appropriate metadata or code annotations.
Join the conversation! Your thoughts help the community grow.