Command-line applications often have a simple job: start, perform work, and exit.
For these applications, startup time and memory consumption can matter more than raw throughput. A CLI tool that runs for only a few hundred milliseconds does not have much opportunity to amortize runtime initialization costs.
This is one reason NativeAOT is interesting for .NET command-line applications.
NativeAOT compiles an application ahead of time into native code. The resulting application is self-contained for its target runtime environment and does not require the normal JIT compilation process at startup. Microsoft documents faster startup and smaller memory footprints as key NativeAOT benefits.
.NET 11 continues improving the NativeAOT experience. In .NET 11 Preview 6, the SDK added support for the NativeAOT CLI serving the full command surface, while runtime work also included faster interface dispatch.
But one important question remains:
How much does NativeAOT actually change startup time and memory usage for a real CLI application?
The answer should be measured rather than assumed.
What NativeAOT Changes
A conventional .NET application normally follows a runtime path similar to:
CLI command
↓
.NET host
↓
Runtime initialization
↓
JIT compilation
↓
Application execution
↓
Exit
A NativeAOT application changes the execution model:
CLI command
↓
Native executable
↓
Application execution
↓
Exit
The application is compiled ahead of time during publishing rather than relying on JIT compilation during execution.
For example, NativeAOT can be enabled in a project file with:
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
A platform-specific publish can then be performed with:
dotnet publish -c Release -r win-x64
The same model can target Linux:
dotnet publish -c Release -r linux-x64
NativeAOT is platform-specific, so the benchmark must always record the operating system and architecture being tested.
Why CLI Applications Are Good NativeAOT Candidates
A long-running server has time to amortize startup overhead.
A short-lived command-line tool does not.
Consider a command:
mytool generate-report
Suppose the actual business operation takes only 100 milliseconds. If process startup, runtime initialization, and JIT work add significant overhead, startup becomes a noticeable portion of the total execution time.
This becomes even more important for tools executed repeatedly:
CI/CD pipelines
Developer tooling
Build utilities
Code generators
Migration tools
Automation scripts
Cloud jobs
Container entrypoints
For these scenarios, the relevant metric is not simply application throughput.
It is:
Time-to-useful-work
NativeAOT Is Not Automatically Faster in Every Scenario
NativeAOT has strong startup characteristics, but developers should avoid turning that into an assumption that every NativeAOT application will be faster overall.
A CLI application can spend most of its execution time doing:
In those cases, startup improvements may have limited impact on total execution time.
For example:
Application A
Startup: 80 ms
Work: 20 ms
Total: 100 ms
versus:
Application B
Startup: 80 ms
Work: 2,000 ms
Total: 2,080 ms
Reducing startup from 80 ms to 20 ms is significant for Application A but relatively small for Application B.
That is why benchmarking needs realistic workloads.
Build a Benchmark CLI
Start with a small console application.
For example:
using System.Diagnostics;
using System.Text.Json;
var stopwatch = Stopwatch.StartNew();
var data = Enumerable.Range(1, 100_000)
.Select(x => new
{
Id = x,
Name = $"Item-{x}",
Value = x * 1.25
})
.ToArray();
var json = JsonSerializer.Serialize(data);
stopwatch.Stop();
Console.WriteLine(
$"Generated {data.Length} items");
Console.WriteLine(
$"JSON size: {json.Length:N0} characters");
Console.WriteLine(
$"Work time: {stopwatch.Elapsed.TotalMilliseconds:N2} ms");
This gives the benchmark a small amount of realistic work rather than measuring only an empty Main method.
For more meaningful testing, add the actual workload your production CLI performs.
Create Two Publish Variants
The benchmark should compare at least two builds.
Standard .NET Publish
dotnet publish -c Release -r win-x64
NativeAOT Publish
dotnet publish -c Release -r win-x64 -p:PublishAot=true
NativeAOT publishing can also be configured directly in the project file.
This makes the experiment easier to reproduce:
<PropertyGroup>
<PublishAot>true</PublishAot>
</PropertyGroup>
The important point is to compare equivalent Release builds targeting the same runtime identifier.
Measure Cold Startup
Cold startup measures the cost of launching a process without relying on recently warmed operating-system caches.
A simple external benchmark can execute the program repeatedly and record elapsed process time.
For example, a PowerShell harness can be used:
$results = @()
1..20 | ForEach-Object {
$start = Get-Date
& ".\mytool.exe" benchmark
$elapsed = (Get-Date) - $start
$results += $elapsed.TotalMilliseconds
}
$results | Measure-Object -Minimum -Maximum -Average
This is not a laboratory-grade benchmarking framework, but it is useful for an initial comparison.
For serious performance analysis, use a dedicated benchmarking methodology and control background system activity.
Measure Memory Usage Separately
Startup time and memory consumption are different measurements.
A process can start quickly but allocate substantially more memory during its workload.
Measure at least:
Working set
Private memory
Peak memory
Managed allocations
The most useful metric depends on what problem you are trying to solve.
For containerized workloads, peak memory can be especially important because memory limits can result in process termination.
For developer machines, resident memory may be more useful because it represents the application's physical memory footprint during execution.
Measure the Right Workload
A weak benchmark looks like this:
Console.WriteLine("Hello");
That may demonstrate process startup, but it does not tell you whether NativeAOT is beneficial for your actual application.
A better CLI benchmark might perform:
Parse command-line arguments
↓
Read configuration
↓
Load input file
↓
Deserialize JSON
↓
Process records
↓
Serialize output
↓
Write result
Now the comparison reflects a workload that resembles a real command-line utility.
Benchmark Multiple Execution Counts
One execution is not enough.
Run the application multiple times:
1 execution
10 executions
50 executions
100 executions
This reveals whether startup overhead becomes meaningful when the CLI is repeatedly invoked.
For example:
| Scenario | Standard .NET | NativeAOT |
|---|
| Cold startup | Measure | Measure |
| Warm startup | Measure | Measure |
| 10 executions | Measure | Measure |
| 100 executions | Measure | Measure |
| Peak memory | Measure | Measure |
| Output size | Measure | Measure |
Do not fill these values with assumptions. The purpose of the benchmark is to obtain them from the target application and environment.
Measure Binary Size Too
Startup and memory are the primary measurements for this article, but deployment size is another useful metric.
NativeAOT produces a self-contained native executable, but self-contained applications include the runtime components they need.
Record:
Published application size
Executable size
Number of deployment files
Container image size
Do not assume that NativeAOT always means the smallest possible artifact.
Compare the actual published outputs.
NativeAOT vs ReadyToRun
NativeAOT should also be distinguished from ReadyToRun.
ReadyToRun precompiles assemblies to reduce the amount of JIT work required during startup, but the application still contains IL and can still use the runtime's JIT capabilities.
A simplified comparison is:
| Deployment | JIT at runtime | Native executable | Typical goal |
|---|
| Framework-dependent | Yes | No | Smaller deployment |
| Self-contained | Yes | No | Runtime independence |
| ReadyToRun | Reduced | No | Faster startup |
| NativeAOT | No | Yes | Fast startup and reduced runtime footprint |
This makes ReadyToRun an interesting additional benchmark target.
Instead of comparing only two configurations, a more complete experiment can compare:
Framework-dependent
↓
Self-contained
↓
ReadyToRun
↓
NativeAOT
NativeAOT Compatibility Matters
NativeAOT has constraints that can affect existing applications.
The official NativeAOT documentation lists limitations including dynamic assembly loading, runtime code generation, and some reflection scenarios. NativeAOT also relies on trimming.
For example, code that dynamically loads an assembly:
var assembly = Assembly.LoadFile(
"/plugins/customer-plugin.dll");
may not fit naturally into a NativeAOT deployment model.
Similarly, applications heavily dependent on runtime code generation require careful compatibility analysis.
Before benchmarking performance, make sure the application can actually be published and executed successfully as NativeAOT.
Watch for Reflection and Trimming Issues
NativeAOT changes the assumptions around runtime discoverability.
Code such as:
var type = Type.GetType(typeName);
can become problematic when the required type cannot be statically identified.
Source generation can often provide a better approach.
For JSON serialization, for example, source-generated metadata can reduce dependence on runtime reflection:
[JsonSerializable(typeof(Customer[]))]
internal partial class AppJsonContext
: JsonSerializerContext
{
}
Then:
var json = JsonSerializer.Serialize(
customers,
AppJsonContext.Default.CustomerArray);
This type of design is particularly useful when building AOT-friendly applications.
Benchmark Startup Distribution, Not Just Average
Average startup time alone can hide important behavior.
Suppose 10 runs produce:
20 ms
21 ms
20 ms
22 ms
21 ms
20 ms
21 ms
20 ms
95 ms
21 ms
The average is affected by one unusually slow execution.
Record at least:
Minimum
Median
Average
P95
Maximum
The median tells you what a typical execution looks like, while P95 shows the slower end of the distribution.
For CI/CD tooling, those tail values can matter because repeated startup delays accumulate.
Avoid Benchmarking on a Busy Machine
Background processes can significantly affect process startup.
Before comparing builds:
Use the same machine.
Use the same OS version.
Use the same CPU architecture.
Use the same input.
Use the same configuration.
Use Release builds.
Minimize background workloads.
Run enough iterations.
Separate cold and warm measurements.
The benchmark should change only the variable being studied.
Common Benchmarking Mistakes
Comparing Debug Builds
Debug builds are not representative of production deployment performance.
Use Release builds.
Changing the Workload
If the NativeAOT version processes fewer records, the comparison is invalid.
Use identical inputs.
Measuring Only One Run
One measurement is not a benchmark.
Run multiple iterations.
Measuring Only Startup
Startup is important, but total execution time and memory consumption may matter more for your actual application.
Ignoring Deployment Size
NativeAOT can change deployment characteristics in addition to runtime performance.
Record artifact size as part of the experiment.
Assuming NativeAOT Is Always Better
NativeAOT is a deployment model, not a universal performance switch.
Measure the workload.
A Practical Benchmark Matrix
For a production-oriented experiment, use a matrix like this:
| Metric | Standard | ReadyToRun | NativeAOT |
|---|
| Cold startup | Measure | Measure | Measure |
| Warm startup | Measure | Measure | Measure |
| Median startup | Measure | Measure | Measure |
| P95 startup | Measure | Measure | Measure |
| Peak memory | Measure | Measure | Measure |
| Total execution time | Measure | Measure | Measure |
| Published size | Measure | Measure | Measure |
| Container image size | Measure | Measure | Measure |
The important word is measure.
Performance claims should come from the target application and hardware rather than generic assumptions.
Best Practices
Benchmark NativeAOT using the actual production workload.
Compare equivalent Release configurations.
Keep the runtime identifier and hardware constant.
Measure cold and warm startup separately.
Record median, P95, and maximum startup time.
Measure peak memory independently from startup.
Include total execution time.
Record published artifact size.
Test NativeAOT compatibility before performance analysis.
Watch for reflection, trimming, and dynamic-code dependencies.
Benchmark ReadyToRun when startup optimization is the primary goal.
Repeat the experiment after runtime or SDK upgrades.
Frequently Asked Questions
Does NativeAOT eliminate the .NET runtime?
NativeAOT applications are self-contained and compiled ahead of time into native code. They do not require the normal installed .NET runtime to execute. They still contain the runtime components required by the application.
Is NativeAOT always faster than JIT?
No. NativeAOT is particularly attractive for startup-sensitive workloads, but total application performance depends on the workload, APIs used, hardware, and application architecture.
Is NativeAOT suitable for CLI tools?
Yes. Short-lived command-line applications are a strong scenario to evaluate because startup overhead can represent a significant portion of total execution time.
Does NativeAOT reduce memory usage?
It can produce a smaller memory footprint, but the actual result depends on the application and workload. Measure the application rather than relying on a generic percentage.
Can every .NET application use NativeAOT without changes?
No. NativeAOT has compatibility and trimming constraints, particularly around dynamic loading, runtime code generation, and some reflection-heavy designs.
Conclusion
NativeAOT gives .NET developers another way to optimize command-line applications where startup time, memory consumption, and deployment characteristics matter.
The important change is not simply publishing with:
-p:PublishAot=true
The real engineering question is whether that deployment model improves the application that you actually run.
A useful benchmark should compare standard .NET, ReadyToRun where relevant, and NativeAOT using the same workload and environment. Measure startup distribution, total execution time, peak memory, and deployment size.
For short-lived CLI tools, the results can be particularly valuable because startup overhead represents a much larger percentage of total execution.
The best NativeAOT decision is therefore not based on the claim that AOT is faster.
It is based on evidence from your application, your workload, and your deployment environment.