.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.

This is one of the biggest areas to investigate before migrating an existing application.

Dynamic Code Can Be a Problem

Some libraries generate code dynamically at runtime.

Examples can include:

  • Runtime proxies

  • Dynamic serializers

  • Expression compilation

  • Runtime assembly generation

  • Certain reflection-heavy frameworks

An application may work correctly under the normal JIT deployment model and then require changes for Native AOT.

This is why compatibility testing should happen before performance testing.

There is no value in benchmarking an AOT build that cannot correctly execute the application's actual features.

A Practical Testing Process

A reliable Native AOT evaluation can follow these steps.

Step 1: Choose a Representative Application

Do not start with a toy application unless you are learning how the technology works.

For a real migration decision, use a representative service or tool.

Step 2: Build the Normal Version

Publish the application using the normal deployment model.

For example:

dotnet publish -c Release

Step 3: Enable Native AOT

Add the appropriate project configuration:

<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>

Then publish for the target platform:

dotnet publish -c Release -r linux-x64

Step 4: Verify Functionality

Before measuring performance, verify:

  • Application startup

  • API behavior

  • Serialization

  • Configuration

  • Logging

  • Database access

  • Authentication

  • Error handling

  • External integrations

Step 5: Measure Startup

Run the same executable repeatedly and record startup behavior.

Step 6: Measure Memory

Measure both startup and steady-state memory.

Step 7: Measure Throughput

Run realistic workloads against both versions.

Step 8: Compare Results

Use the same hardware, operating system, configuration, and input data.

Benchmarking Application Code

For CPU-focused comparisons, BenchmarkDotNet can be useful.

For example:

using BenchmarkDotNet.Attributes;

public class ProcessingBenchmark
{
    private readonly int[] values = new int[100_000];

    [Benchmark]
    public long Process()
    {
        long total = 0;

        for (int i = 0; i < values.Length; i++)
        {
            total += values[i] * 2;
        }

        return total;
    }
}

This measures application code rather than complete process startup.

That distinction matters.

Use process-level measurements for startup and application-level benchmarks for CPU operations.

Do not use one benchmark to answer every performance question.

What Should You Measure?

A useful comparison might look like this:

Metric

Why it matters

Startup time

Important for short-lived processes

First request latency

Useful for services

Steady-state latency

Shows normal runtime behavior

Throughput

Shows processing capacity

Peak memory

Important for constrained environments

Steady-state memory

Useful for long-running services

CPU usage

Helps identify processing differences

Binary size

Important for deployment and distribution

Build time

AOT can increase build complexity

Compatibility

Required before performance comparisons

This gives a much more complete picture than simply comparing execution time.

Common Mistakes

Comparing Debug Builds

Always use the appropriate Release configuration when performing performance testing.

dotnet publish -c Release

Testing Different Hardware

If the JIT version runs on one server and the AOT version runs on another, the comparison is difficult to interpret.

Use the same environment where possible.

Measuring Only Startup

Native AOT can change startup characteristics, but that does not tell you how the application behaves after it has been running for hours.

Measuring Only CPU Time

A service may have excellent CPU performance but still have a memory or I/O bottleneck.

Ignoring Compatibility

An application that cannot use an important dependency under AOT is not a successful AOT candidate regardless of benchmark results.

Best Practices

Keep a Baseline

Before changing the deployment model, record the existing application's performance.

Use Production-Like Workloads

If production processes JSON requests, test JSON requests.

If production processes files, test representative files.

Test on the Target Architecture

An AOT binary is built for a specific target environment.

Test it where it will actually run.

Separate Startup and Steady-State Tests

Do not mix the two measurements.

Test Multiple Runs

One run can be affected by system activity and caching.

Use repeated measurements and look for consistent results.

Check Dependencies Early

Review the libraries used by the application before committing to an AOT migration.

Monitor Real Applications

If the application is deployed, continue measuring real behavior after the change.

Advantages of Native AOT

Native AOT can provide several practical benefits for suitable applications:

  • Less JIT work at startup

  • Native executable deployment

  • Potentially useful startup characteristics

  • Reduced dependency on runtime compilation

  • Useful fit for certain short-lived workloads

  • Can simplify deployment for some environments

The actual benefit depends on the application.

Disadvantages and Trade-Offs

Native AOT also introduces trade-offs:

  • Some .NET features require additional consideration

  • Reflection-heavy applications may need changes

  • Some libraries may not be AOT-compatible

  • Builds can become more involved

  • Native binaries are target-specific

  • Debugging and diagnostics can differ from a normal managed deployment

  • Dynamic code generation has restrictions

These are not reasons to avoid Native AOT.

They are reasons to evaluate it against the actual application.

When Native AOT Is Worth Testing

Native AOT is particularly worth evaluating for:

  • Command-line tools

  • Short-lived workers

  • Small services

  • Container workloads

  • Applications where startup matters

  • Resource-constrained environments

It may be less compelling when:

  • The application runs continuously for long periods.

  • Startup time is insignificant.

  • The application relies heavily on dynamic runtime behavior.

  • Dependencies are not compatible with AOT.

  • The main bottleneck is database or network I/O.

Again, these are starting points rather than universal rules.

Troubleshooting Native AOT Builds

If publishing fails, start by examining the build output.

Common areas to investigate include:

Reflection

Look for code that discovers types or members dynamically.

Dynamic Code Generation

Check whether a dependency requires runtime-generated code.

Serialization

Verify that the serializer and configuration are compatible with the AOT deployment model.

Dependencies

A library used by the application may have AOT limitations.

Platform Target

Make sure the runtime identifier matches the actual deployment environment.

For example:

dotnet publish -c Release -r linux-x64

should not be used to produce the binary intended for a different architecture.

A Simple Evaluation Matrix

Before adopting Native AOT, document the results.

Test

JIT Deployment

Native AOT

Observation

Startup

Measure

Measure

Compare repeated runs

First request

Measure

Measure

Use same request

Throughput

Measure

Measure

Same workload

Peak memory

Measure

Measure

Same environment

Steady-state memory

Measure

Measure

Long-running test

CPU usage

Measure

Measure

Same workload

Build time

Measure

Measure

Include CI build

Compatibility

Verify

Verify

All required features

The purpose of this table is not to produce a single winner.

It gives the team enough information to decide whether the deployment model fits the application's requirements.

Summary

Native AOT changes how a .NET application is compiled and deployed. Instead of depending on normal JIT compilation for application code at runtime, the application is compiled ahead of time into a native executable for a specific target environment.

The most useful way to evaluate Native AOT is through measurement. Test startup time, first-request behavior, steady-state performance, memory usage, CPU usage, binary size, build time, and application compatibility.

Native AOT can be a good fit for applications where startup and deployment characteristics matter, but it is not a universal performance switch. Applications that rely heavily on reflection, dynamic code generation, or incompatible dependencies may require additional changes.

For developers considering Native AOT, the safest approach is to establish a baseline, build an AOT version, verify that all required functionality still works, and then compare both versions using the same hardware and realistic workloads. That gives you evidence about whether Native AOT is useful for your application instead of relying on a generic performance claim.