Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614491)
- First published
Cite this article(DOI: 10.5281/zenodo.21614490)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). What Is .NET Native AOT? - How It Differs from JIT and Trimming. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614490 https://comcomponent.com/en/blog/2026/03/13/001-dotnet-native-aot-what-is/
- DOI (latest version)
- 10.5281/zenodo.21614490
- DOI (this version)
- 10.5281/zenodo.22217138
In Calling a C# Native AOT DLL from C/C++ we already covered using Native AOT to call C# from C/C++. Honestly, though, it would have been kinder to put what Native AOT actually is first. The order got slightly reversed.
Discussions of Native AOT are notorious for terms blurring together right at the start.
- Is this about getting rid of the JIT?
- How does it differ from self-contained and single-file?
- Is it in the same family as ReadyToRun?
- What is actually happening when a flood of trimming warnings appears?
- Can WPF / WinForms / ASP.NET Core all use it with the same level of comfort?
When these blur together, Native AOT starts to look like either magic that just makes things faster or, conversely, a scary thing full of restrictions. Both views are a bit sloppy.
flowchart TB
accTitle: What happens when the terms blur together
accDescr: When terms such as JIT, self-contained, ReadyToRun, and trimming all blur together, Native AOT looks either like magic that just makes things faster or like a scary pile of restrictions, and both views are sloppy.
mix["Terms blur together"] --> m1["Looks like speed magic"]
mix --> m2["Looks full of scary restrictions"]
sort["Separate the terms first"] --> fair["An option with a visible place"]
Figure 1: The root of the confusion is tangled vocabulary. Start by pulling the words apart.
In this article, assuming the current practical landscape of .NET 8 and later, we sort out these four things first.
- What Native AOT really is
- What you gain, and where things get tough
- How it differs from ReadyToRun and trimming
- Which kinds of apps make for a gentle first attempt
Table of Contents
- The Conclusion First (In One Line)
- The Tables to Look at First
- 2.1. The Vocabulary Around Native AOT
- 2.2. JIT vs. ReadyToRun vs. Native AOT
- The Big Picture of Native AOT (Diagram)
- What Native AOT Gives You
- 4.1. Startup Tends to Get Lighter
- 4.2. No Need to Assume a Pre-Installed Runtime
- 4.3. Good Fit for Restricted Execution Environments
- Where Native AOT Gets Tough
- 5.1. Reflection and Dynamic Code Generation
- 5.2. You Have to Think Trimming-First
- 5.3. Publishing per Platform
- 5.4. Windows Desktop / COM Contexts Call for Real Caution
- Minimal Steps
- 6.1. The
csproj - 6.2. Publishing
- 6.3. How to Write JSON
- 6.1. The
- Cases Where It Fits
- Cases Where It Doesn’t
- Pitfalls
- Summary
- References
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (16 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
1. The Conclusion First (In One Line)
- Native AOT is a way of publishing a .NET app that ahead-of-time compiles it to native code at publish time and ships that.
- Because it does not use a runtime JIT, startup time and memory footprint tend to improve, and it is easy to deploy to machines without the .NET runtime installed.
- In exchange, it gets along poorly with unrestricted reflection, dynamic code generation, built-in COM, and libraries that do not support trimming.
- In other words, rather than magic that makes things faster, it is a publishing model that gives up a little of the dynamic world and leans toward the static one, for the sake of startup, distribution, and execution-environment constraints.
Native AOT is a mechanism for shipping .NET like a native app, not a simple checkbox for faster compilation.
flowchart TB
accTitle: What you gain and what you give up
accDescr: Through ahead-of-time compilation at publish time, Native AOT gains startup time, memory footprint, and runtime-free distribution, and in exchange gives up compatibility with unrestricted reflection, dynamic code generation, and built-in COM.
aot["Native AOT"] --> gain["What you gain"]
aot --> lose["What you give up"]
gain --> g1["Lighter startup, memory, and deployment"]
lose --> l1["Unrestricted reflection"]
lose --> l2["Dynamic code generation and built-in COM"]
Figure 2: Not magic that makes things faster, but a trade that gives up a little of the dynamic world for the static one.
2. The Tables to Look at First
2.1. The Vocabulary Around Native AOT
Separating these terms up front makes everything that follows easier.
| Term | What it does | Relationship to Native AOT |
|---|---|---|
| JIT | Generates native code from IL at runtime | Native AOT does this work ahead of time |
| self-contained | Ships the .NET pieces needed to run along with the app | Native AOT belongs to this family of thinking |
| single-file | Bundles the deliverable into one file | Distinct from the essence of Native AOT, but the end result often looks similar |
| trimming | Removes unused code | Practically a prerequisite under Native AOT |
| ReadyToRun | Keeps the IL but front-loads some of the JIT’s work | Similar-sounding but a genuinely different thing |
| source generator | Moves runtime dynamic behavior into build-time code generation | Pairs well with Native AOT |
The confusing part is that Native AOT is less a single feature and more a publishing model that works hand in hand with self-contained, trimming, source generation, and RID-pinned publishing.
flowchart TB
accTitle: The machinery that moves along with Native AOT
accDescr: Native AOT is not a single feature but a publishing model that works in combination with self-contained, trimming, source generation, and RID-pinned publishing.
aot["Native AOT (publishing model)"] --> s1["Same family as self-contained"]
aot --> s2["Trimming is practically a prerequisite"]
aot --> s3["Pairs well with source generators"]
aot --> s4["Publish pinned to a RID"]
Figure 3: Native AOT is not a standalone feature but a publishing model that moves as a set with the machinery around it.
2.2. JIT vs. ReadyToRun vs. Native AOT
This too is fastest to absorb as one table.
| Aspect | Ordinary JIT execution | ReadyToRun | Native AOT |
|---|---|---|---|
| Runtime JIT | Used | Still used in some cases | Not used |
| Contents of the deliverable | Mostly IL | IL + pre-generated code | Mostly a native executable |
| Startup | Baseline | Easy to improve | Very easy to improve |
| Compatibility | Broadest | Broad | Strongly constrained |
| Dynamic features | Easy to use | Mostly easy to use | Heavily restricted |
| Best suited for | General .NET development | Improving startup as a first step | Aggressively pursuing startup, distribution, and restricted environments |
If ReadyToRun is the direction of making the JIT’s life a little easier, Native AOT is the direction of not assuming a runtime JIT at all. Both carry the letters AOT, but the two are worlds apart in practice.
flowchart TB
accTitle: The difference in direction between ReadyToRun and Native AOT
accDescr: ReadyToRun keeps the IL and front-loads a little of the JIT work to make it easier, while Native AOT does not assume a runtime JIT at all, so the same word AOT covers two quite different things.
q{"What do you want from the JIT"}
q -->|"Make its job a little easier"| r2r["ReadyToRun (IL stays)"]
q -->|"Do not assume it at all"| aot["Native AOT (native-centric)"]
r2r --> soft["Compatibility stays broad"]
aot --> hard["Fast startup but strong constraints"]
Figure 4: Even under the same AOT label, making the JIT easier and not assuming the JIT are different things.
With that in place, the question of which deployment form to actually pick fits on one page like this.
flowchart TD
S["Decide the deployment form"] --> Q1{"Can you install the .NET runtime on the target?"}
Q1 -- "You can" --> Q2{"Do you need to cut startup time?"}
Q2 -- "Not that much" --> P1["framework-dependent<br/>ordinary publish"]
Q2 -- "You do" --> P2["ReadyToRun<br/>startup gains with compatibility intact"]
Q1 -- "You do not want to / cannot" --> Q3{"Do you depend on reflection, dynamic code generation, or built-in COM?"}
Q3 -- "You do" --> P3["self-contained<br/>bundle with single-file if needed"]
Q3 -- "You do not" --> Q4{"Is the target a console app, worker, or small API?"}
Q4 -- "Yes" --> P4["Native AOT"]
Q4 -- "No" --> P5["self-contained first<br/>revisit after reducing dynamic dependencies"]
Figure 5: How to pick a deployment form. Branch first on whether the runtime can be placed there, then on how far dynamic machinery can be reduced.
The first branch is whether you can place the runtime on the deployment target, and the next one is how far you can reduce the dynamic machinery. self-contained and single-file are about how you ship; ReadyToRun and Native AOT are about when native code gets generated. In practice you end up combining them.
3. The Big Picture of Native AOT (Diagram)
Roughly sketched, Native AOT looks like this.
flowchart LR
Src["C# / .NET source code"] --> IL["IL assemblies"]
IL -->|Normal execution| JIT["Runtime JIT"]
JIT --> Run1["App runs"]
IL -->|dotnet publish + PublishAot| Analyze["AOT / trim analysis"]
Analyze --> Trim["Removal of unused code"]
Trim --> AOT["Native code generation"]
AOT --> Run2["RID-specific executable"]
Figure 6: Normal execution JIT-compiles at runtime, while Native AOT front-loads analysis, removal, and native generation to publish time.
Ordinary .NET first produces IL, then JIT-compiles only what is needed at runtime. Native AOT front-loads a large portion of that later stage to publish time.
The crucial point here is that at publish time, the toolchain has to know essentially all of the code that will be needed at runtime. That is where the ground rules for how you are allowed to write code change.
- Discovering types at runtime
- Growing new code at runtime
- Loading assemblies at runtime
- Deferring resolution on the assumption that it will somehow work out at runtime
Code written in these styles suddenly stops getting along with Native AOT.
flowchart TB
accTitle: Everything has to be known at publish time
accDescr: Native AOT needs to know essentially all of the code required at runtime by publish time, so styles such as finding types at runtime, generating code at runtime, loading assemblies at runtime, and deferring resolution get along badly with it.
need["Required code fixed at publish time"] --> ng1["Find types at runtime"]
need --> ng2["Generate code at runtime"]
need --> ng3["Load assemblies at runtime"]
need --> ng4["Get by with deferred resolution"]
ng1 -.-> bad["All of these clash with AOT"]
Figure 7: The core of the changed ground rules. The more a style decides things at runtime, the harder it collides with Native AOT.
4. What Native AOT Gives You
4.1. Startup Tends to Get Lighter
The most visible payoff of Native AOT is, unsurprisingly, startup.
- CLI tools
- Short-lived processes
- Serverless-style startup
- Container startup and rollover
- Monitoring tools and small resident processes
In these scenarios the JIT cost is easy to see, and because Native AOT front-loads it, the first moments get lighter.
Memory footprint also tends to improve, which helps when you want to pack more onto the same number of machines. On the cloud side in particular, where many copies of the same process spin up, this difference compounds steadily.
4.2. No Need to Assume a Pre-Installed Runtime
An app published with Native AOT is easy to run on machines without the .NET runtime installed.
This is quietly significant.
- You do not want to tell the deployment target to install the .NET 9 Runtime first
- You want slimmer container images
- You want to drop a single small tool somewhere and have it run
- You do not want, or are not allowed, to permit JIT in the execution environment
In situations like these, simply removing the assumption that a runtime has to be provisioned separately takes a great deal of noise out of the whole discussion.
The phrase “no runtime needed” here means the deployment target does not need a separate .NET install. It does not mean that the runtime-equivalent parts inside the app disappear entirely.
flowchart TB
accTitle: What no runtime needed actually means
accDescr: With Native AOT, no runtime needed means the deployment target does not need a separate .NET install; it does not mean that the runtime-equivalent parts inside the app disappear entirely.
word["The phrase no runtime needed"] --> ok["No separate .NET install on the target"]
word -.-> ngx["The runtime-equivalent parts do not disappear"]
ok --> merit["Fewer prerequisites for deployment and startup"]
Figure 8: No runtime needed is a statement about the deployment target. The runtime-equivalent parts are still inside the app.
4.3. Good Fit for Restricted Execution Environments
Because Native AOT does not use a runtime JIT, it is easier to run in environments where JIT is not permitted.
This lands more on the cloud, container, and mobile side than on desktop. Still, even in a Windows development context, it is a genuine win in the sense of reducing extra prerequisites at the deployment target.
5. Where Native AOT Gets Tough
5.1. Reflection and Dynamic Code Generation
This is the heart of Native AOT’s constraints.
- Dynamic loading such as
Assembly.LoadFile - Runtime code generation such as
System.Reflection.Emit - Reflection that walks types without limits at runtime
- Code that composes generics freely at runtime
These make it hard to pin down the required code at publish time, so they become breeding grounds for AOT warnings.
Of course, it is not as simple as one line of reflection putting you out of the game. But the trend holds without exception: the more a design leans toward looking at runtime and deciding then, the tougher things get.
The warning names you will see most often are the RequiresDynamicCode family.
They mean that the call in question may break under AOT, so it is safer not to suppress them casually.
When you take on Native AOT, a useful mental model is that you reduce runtime cleverness and increase build-time explicitness.
flowchart TB
accTitle: From runtime cleverness to build-time explicitness
accDescr: Dynamic loading, runtime code generation, and unrestricted reflection are breeding grounds for AOT warnings, so shift the design toward less runtime cleverness and more build-time explicitness.
dynamicway["Design that relies on runtime cleverness"] --> warn["Warnings in the RequiresDynamicCode family"]
warn --> shift["Add build-time explicitness"]
shift --> calm["Required code is fixed at publish time"]
warn -.-> nosup["Do not suppress casually"]
Figure 9: There is only one direction for facing the central constraint: turn deciding at runtime into declaring at build time.
5.2. You Have to Think Trimming-First
Native AOT is deeply intertwined with trimming. What is easy to miss here is that not just your own code but the way your dependency libraries are written matters too.
The usual suspects are these.
- Reflection-based serializers
- DI / plugin setups that gather types through runtime scanning
- Mechanisms that look up types by string name and instantiate them
- Libraries that lean on dynamic proxies or IL generation
If warnings appear here and you shrug them off because the publish succeeded, the bill arrives later and it is steep. Under Native AOT, warnings generally deserve a serious read.
flowchart TB
accTitle: Trimming is not decided by your code alone
accDescr: With trimming, not only your own code but also how your dependency libraries are written matters, so reflection-based serializers, DI that scans at runtime, type resolution from string names, and libraries leaning on dynamic proxies all need attention.
trim["Whether trimming succeeds"] --> own["How your own code is written"]
trim --> dep["How dependency libraries are written"]
dep -.-> ex["Reflection-based and runtime-scanning libraries"]
dep --> care["Read the warnings seriously and check"]
Figure 10: What is easy to miss is the dependency side. The attitude of “the publish succeeded, so we are fine” gets expensive later.
That said, “read them seriously” alone does not tell you what to do, so here is the order of priority for dealing with them. The official documentation recommends trying them in this order too.
| Order | What to do | When to use it |
|---|---|---|
| 1 | Stop using reflection | Activator.CreateInstance(Type) can be replaced with a generic argument, or the work can move to a source generator |
| 2 | Add DynamicallyAccessedMembers |
Reflection is necessary, but the target type is known at compile time |
| 3 | Add RequiresUnreferencedCode |
The type name is decided from a runtime string, so static analysis is fundamentally impossible |
| 4 | Suppress with UnconditionalSuppressMessage |
The last resort, when none of the above works and you have confirmed it is safe |
The most common first step by far is case 2: the type is known, yet the warning still fires.
For example, the following code produces IL2070.
// IL2070: 'this' argument does not satisfy 'DynamicallyAccessedMemberTypes.PublicMethods'
void PrintMethodNames(Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
Declare with an attribute that calling GetMethods() requires the public methods to be kept, and the warning goes away.
using System.Diagnostics.CodeAnalysis;
void PrintMethodNames(
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type type)
{
foreach (var method in type.GetMethods())
{
Console.WriteLine(method.Name);
}
}
// If the caller passes the type with typeof, the requirement is satisfied automatically
PrintMethodNames(typeof(DateTime));
The API you call and the annotation you need line up fairly directly. GetMethod / GetMethods need PublicMethods, GetProperty / GetProperties need PublicProperties, and Activator.CreateInstance needs PublicParameterlessConstructor or PublicConstructors.
DynamicallyAccessedMemberTypes.All is convenient, but it keeps every member of the target type, which inflates size, and the members it keeps can drag in warnings of their own, so specify the minimum you actually need as a rule.
Also, when adding the attribute does not clear the warning, trace back from the place that uses reflection to its callers and check that every step on the path carries it. If a single step is missing, the requirement is cut off there.
flowchart TB
accTitle: When the warning survives after adding the attribute
accDescr: Specify DynamicallyAccessedMembers as narrowly as possible, and when a warning does not clear, trace back from the place using reflection to its callers and check that every step on the path carries the attribute, because one missing step cuts the requirement off there.
warnleft["Warning remains after adding the attribute"] --> back["Trace back to the callers"]
back --> chain["Check every step on the path carries it"]
chain -.-> cut["One missing step cuts the requirement off"]
warnleft -.-> minrule["As a rule specify the minimum needed"]
Figure 11: Annotate the path, not a single point. One gap along the way and the requirement stops right there.
5.3. Publishing per Platform
Native AOT publishes pinned to a RID (Runtime Identifier).
That is, this is not a world where something built for win-x64 runs as is on linux-x64.
- Windows x64
- Windows Arm64
- Linux x64
- Linux Arm64
- macOS Arm64
You produce a separate publish output per target, like that.
This feels much more like a native app than ordinary framework-dependent .NET does.
5.4. Windows Desktop / COM Contexts Call for Real Caution
In KomuraSoft’s line of work, this part matters especially.
On Windows, Native AOT has no built-in COM. On top of that, WPF gets along poorly with trimming and WinForms depends heavily on built-in COM marshalling, so at least at present, neither belongs anywhere near the top of a list of first Native AOT candidates.
It is worth stating what “at present” is based on here. The statements in this article follow the official documentation for the .NET 8 / 9 / 10 generation, as of July 2026. For WPF and WinForms, Microsoft’s “Known trimming incompatibilities” page states explicitly that trimming support is disabled on the .NET SDK side for both - WPF because its heavy dependence on reflection and runtime code inspection leaves it barely functional after trimming, and WinForms because of its heavy dependence on built-in COM marshalling. So it is not a matter of proceeding with caution: as things stand, the SDK stops you. That description may change in the future, so check the same page at the time you read this.
flowchart TB
accTitle: Why WPF and WinForms are blocked
accDescr: Native AOT on Windows has no built-in COM, WPF is barely functional after trimming because of its dependence on reflection and runtime code inspection, and WinForms depends heavily on built-in COM marshalling, so trimming support is disabled on the .NET SDK side for both.
win["Native AOT on Windows"] --> nocom["No built-in COM"]
wpf["WPF"] --> refl["Heavy reflection dependency"]
wf["WinForms"] --> commar["Heavy COM marshalling dependency"]
refl --> off["Trimming disabled on the SDK side"]
commar --> off
Figure 12: Not proceed with caution but blocked by the SDK today. Do not make the desktop app body your first candidate.
In short, the following
- Converting a WPF / WinForms app body straight to Native AOT
- Bringing COM interop along with your usual instincts intact
tend to pile up publish-time warnings and runtime constraints all at once, and the cost of dealing with them spikes.
Conversely, the following
- Console apps
- Workers
- Small web APIs
- Native-interop pieces that can be flattened onto a C function boundary
are far more straightforward as entry points.
If you need COM, there are cases where staying on the JIT, or redesigning around ComWrappers / source-generated COM, is the sounder route.
6. Minimal Steps
Before any of that, publishing with Native AOT requires a native toolchain.
If you just add PublishAot and run dotnet publish, it fails not at compilation but at the final native link step. That is the first gate, so install these first.
| Environment | What you need |
|---|---|
| Windows | Visual Studio 2022 or later. Install the Desktop development with C++ workload with all of its default components |
| Ubuntu 18.04 or later | sudo apt-get install clang zlib1g-dev |
| Alpine 3.15 or later | sudo apk add clang build-base zlib-dev |
| Fedora 39 or later / RHEL 8 or later | sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel |
| macOS | Xcode Command Line Tools (supported on .NET 8 and later) |
In short, it is the compiler toolchain plus the development packages for the libraries the .NET runtime depends on. If you run this in CI, the Native AOT samples in dotnet/samples ship Dockerfiles for both Linux and Windows, so lifting the prerequisite install steps from there is the quick path.
Note that a binary built on Linux runs only on the same Linux version or newer. Something built on Ubuntu 20.04 runs on 20.04 and later, but not on 18.04. Your choice of build environment directly defines how far you can distribute.
flowchart TB
accTitle: The gate before publish and the reach of distribution
accDescr: Publishing with Native AOT requires a native toolchain, and without it the build fails at the final native link step. A binary built on Linux runs only on the same version or newer, so the choice of build environment defines the reach of distribution.
tool{"Is the toolchain installed"}
tool -->|"No"| fail["Fails at the native link step"]
tool -->|"Yes"| bin["One binary per RID"]
bin -.-> range["Linux builds run only on the same version or newer"]
range -.-> pick["Build environment defines the reach"]
Figure 13: The first gate is the toolchain. On Linux, how old the build environment is decides how far you can distribute.
6.1. The csproj
First, add PublishAot to the project file.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PublishAot>true</PublishAot>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
net8.0 is fine for the samples. The thinking itself is essentially the same on .NET 9 / 10.
What matters is not to tack it temporarily onto the dotnet publish command line, but to
keep it in the project all the time, so you see the build / publish analysis on a daily basis.
Note that adding <PublishAot>true</PublishAot> does not suddenly turn your everyday local runs into Native AOT.
Day-to-day dotnet run and normal execution stay on the JIT; the real Native AOT compilation happens at publish time.
flowchart TB
accTitle: Daily life after adding PublishAot
accDescr: Even with PublishAot in the project, everyday dotnet run stays on the JIT and the real Native AOT compilation happens at publish time, which is why keeping the setting in the project and watching the build and publish analysis daily matters.
put["Keep PublishAot in the csproj"] --> daily["Everyday dotnet run stays on the JIT"]
put --> pub["AOT compilation happens at publish time"]
daily -.-> watch["Watch the analysis and warnings daily"]
Figure 14: JIT day to day, AOT at publish. That is exactly why the setting belongs in the project permanently, so you keep seeing the analysis.
6.2. Publishing
For Windows x64, for example, it looks like this.
dotnet publish -c Release -r win-x64
For Linux x64, like this.
dotnet publish -c Release -r linux-x64
The output is RID-pinned. The mindset shifts from one .NET DLL that runs anywhere to an executable built for that OS and architecture.
If you are approaching this from the web API side, starting from a Native AOT template is the easy way in.
dotnet new webapiaot -o MyFirstAotWebApi
For a worker, this one.
dotnet new worker -o WorkerWithAot --aot
6.3. How to Write JSON
A quietly frequent collision point under Native AOT is JSON.
Used with your usual instincts, System.Text.Json leans toward reflection, so leaning into source generation is calmer.
using System.Text.Json;
using System.Text.Json.Serialization;
[JsonSerializable(typeof(AppConfig))]
internal partial class AppJsonContext : JsonSerializerContext
{
}
public sealed class AppConfig
{
public string? Name { get; init; }
public int RetryCount { get; init; }
}
var config = new AppConfig
{
Name = "sample",
RetryCount = 3
};
string json = JsonSerializer.Serialize(config, AppJsonContext.Default.AppConfig);
In practice, the rule of thumb that rarely misses is less “make it Native AOT compatible” and more “do not make the runtime go hunting for types.”
7. Cases Where It Fits
Native AOT tends to click pleasantly in cases like these.
- CLI tools where startup is the star
- Small APIs deployed at scale in containers
- Workers / background services
- Serverless and short-lived processes
- Small .NET components slotted into native apps
- Situations where you do not want to require a pre-installed .NET runtime
What they share is that the boundaries are relatively clear and the dynamic machinery is easy to reduce.
8. Cases Where It Doesn’t
Conversely, there are clear cases where Native AOT should not be your main battlefield from the start.
- The body of an existing large WPF / WinForms application
- Setups premised on built-in COM interop
- Apps where runtime plugin loading is the centerpiece
- Heavy dependence on frameworks that discover types through reflection
- Libraries that use
System.Reflection.Emitor dynamic proxies as a matter of course - Designs with C++/CLI in the middle
For these, ordinary JIT-based .NET, or ReadyToRun, or rethinking where the design boundaries sit, is the sounder route.
9. Pitfalls
Finally, here are the things that are easy to step on in your first round with Native AOT.
- Taking publish warnings lightly
- As noted above, Native AOT warnings are meant to be read seriously.
- The build passes but the publish breaks
- At publish time the analysis runs in earnest across your dependency libraries too, and some things only become visible there.
- Treating ReadyToRun and Native AOT with the same mindset
- The words are similar, but the strength of the constraints is quite different.
- Starting straight from the body of a desktop app
- A console app, a worker, or a small API first is much calmer.
- Writing JSON or configuration binding with your usual habits
- Reflection-premised code comes back to bite later.
- Shipping as if the output were platform-independent
- Native AOT deliverables are RID-pinned.
- Believing that Native AOT makes everything faster
- The stars here are startup, distribution, and the execution environment. Miss that and expectations drift.
With Native AOT, the final verdict comes from dotnet publish, not dotnet build.
Start running it early and the late game gets much less painful.
flowchart TB
accTitle: The publish decides the verdict
accDescr: The build can pass while the publish breaks because at publish time the analysis runs in earnest across dependency libraries too, so what finally decides whether Native AOT works is dotnet publish rather than dotnet build.
buildok["dotnet build passes"] --> notyet["The verdict is not in yet"]
pubx["dotnet publish"] --> deep["Full analysis including dependencies"]
deep --> verdict["This is where the verdict first shows"]
verdict -.-> early["So run publish early"]
Figure 15: The common thread across the pitfalls. Do not relax because the build passed; run the publish early.
10. Summary
In one sentence, Native AOT is a mechanism that shifts a .NET app from a dynamic execution model toward a distribution model that can be pinned down statically.
The points worth keeping in view come down to these five.
- Native AOT ahead-of-time compiles to native code at publish time
- It works very well for startup, memory, and distribution concerns
- In exchange, it is hard on reflection, dynamic code generation, built-in COM, and trimming-incompatible code
- As a first target, a console app, a worker, or a small API is calmer than a desktop app body
- Clearing warnings and verifying early on a publish basis is what matters
Native AOT is not a standard switch to flip on every .NET app. But where startup matters, you want lighter distribution, and you want fewer execution-environment prerequisites, it is a genuinely powerful weapon.
Conversely, in the dense world of WPF / WinForms / COM, ordinary .NET is still often the sounder choice. Once you can tell these apart, Native AOT stops being a difficult new feature and becomes an option with a clearly defined place.
11. References
- Native AOT deployment overview - .NET
- Native AOT deployment overview - .NET (Japanese)
- Introduction to AOT warnings - .NET
- Fixing trim warnings - .NET
- DynamicallyAccessedMembersAttribute Class - .NET
- Prepare .NET libraries for trimming - .NET
- Known trimming incompatibilities - .NET
- How to use source generation in System.Text.Json - .NET
- ASP.NET Core support for Native AOT
- ReadyToRun deployment overview - .NET
- Building native libraries - .NET
- ComWrappers source generation - .NET
- Related post: Calling a C# Native AOT DLL from C/C++
- Related post: Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Practical Multithreading Best Practices: .NET Edition — What to Decide Before You Add More Threads
Keep .NET/C# threads from crashing or hanging. Ride on Task, cut shared mutable state, lock with discipline, stop with CancellationToken,...
Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits
A practical guide to deciding the code scheme for a business system, including product and customer codes. Covers a decision table for me...
What Is the .NET Generic Host? - The Foundation for DI, Configuration, and Logging
What the Generic Host does, seen through its relationship to DI, configuration, logging, IHostedService, and BackgroundService - and wher...
Choosing Between .NET's Three Timers - PeriodicTimer/Timer/DispatcherTimer
Which .NET timer should you use? PeriodicTimer for async loops, Timer for ThreadPool callbacks, DispatcherTimer for WPF UI, plus a decisi...
Calling a C# Native AOT DLL from C/C++
Publish a C# class library as a native DLL with Native AOT and call its UnmanagedCallersOnly entry points from C/C++ - where the setup fi...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
32-bit / 64-bit Interoperability
Topic page for 32-bit / 64-bit interoperability, native boundaries, and related Windows design decisions.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Technical Consulting & Design Review
We help clarify design direction, architectural boundaries, lifetime ownership, and how to handle legacy Windows assets.
Frequently Asked Questions
Common questions about the topic of this article.
- What is Native AOT?
- It is a publishing model that ahead-of-time compiles a .NET app to native code at publish time and ships that. Because there is no runtime JIT, startup time and memory footprint tend to improve, and the app is easy to hand to machines with no .NET runtime installed. In exchange, it gets along poorly with unrestricted reflection, dynamic code generation, built-in COM, and libraries that do not support trimming. Rather than magic that makes things faster, it is a publishing model that gives up a little of the dynamic world and leans toward the static one, for the sake of startup, distribution, and execution-environment constraints.
- What is the difference between Native AOT and ReadyToRun?
- ReadyToRun keeps the IL and front-loads a little of the JIT's work, so the runtime JIT still comes into play in some places. Compatibility stays broad, and dynamic features remain mostly easy to use. Native AOT, by contrast, does not assume a runtime JIT at all: the deliverable is centered on a native executable, startup is much easier to improve, and the constraints get considerably stronger in return. Both carry the letters AOT, but ReadyToRun points toward making the JIT's job a little easier while Native AOT points toward not assuming a JIT at all, and the two feel quite different.
- Can I use Native AOT in WPF or WinForms apps?
- For now, treat that with real caution. Native AOT on Windows has no built-in COM, WPF gets along poorly with trimming, and WinForms depends heavily on built-in COM marshalling, so neither is a good first Native AOT candidate. If you need COM, staying on the JIT or redesigning around ComWrappers and source-generated COM is sometimes the sounder route. As an entry point, console apps, workers, and small web APIs are far more straightforward.
- What kinds of apps is Native AOT a good fit for?
- It suits CLI tools where startup is the star, small APIs deployed at scale in containers, workers and background services, serverless and short-lived processes, small .NET components slotted into native apps, and situations where you do not want to require a pre-installed .NET runtime. What these share is relatively clear boundaries and dynamic machinery that is easy to reduce. It does not suit apps whose centerpiece is runtime plugin loading, or setups that lean heavily on frameworks that discover types through reflection.