Native AOT Compilation in .NET: A Complete Guide
Learn how to compile your .NET apps to native code with Native AOT.
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
- ✓Basic knowledge of C# and .NET
- ✓.NET 8 SDK installed
- ✓Familiarity with command-line tools
- Native AOT compiles C# code directly to machine code, eliminating the need for a JIT compiler or runtime.
- It produces a single-file executable with fast startup and low memory footprint.
- Not all .NET features are supported; reflection and dynamic code generation are limited.
- Ideal for cloud-native, serverless, and containerized applications.
- Requires .NET 8+ and specific project configuration.
Imagine you have a recipe written in a language that needs a translator every time you cook. Native AOT is like translating the entire recipe into your native language once, so you can cook instantly without any translator. Your app becomes a standalone executable that runs directly on the operating system.
In the world of modern software development, performance and efficiency are paramount. Whether you're deploying microservices in Kubernetes, building serverless functions, or creating command-line tools, every millisecond of startup time and every megabyte of memory counts. Traditional .NET applications rely on a Just-In-Time (JIT) compiler that compiles Intermediate Language (IL) to machine code at runtime. While this provides flexibility and cross-platform compatibility, it introduces overhead. Enter Native AOT (Ahead-of-Time) compilation, a game-changer introduced in .NET 7 and refined in .NET 8 and beyond. Native AOT compiles your entire application, including the runtime, into a single native executable. This means no JIT compilation, no IL interpreter, and no runtime dependencies. The result? Blazing-fast startup times, lower memory usage, and smaller deployment artifacts. However, Native AOT comes with trade-offs: limited support for reflection, dynamic code generation, and certain runtime features. In this tutorial, we'll dive deep into Native AOT, exploring its benefits, limitations, and practical usage. You'll learn how to configure your projects, handle common pitfalls, and decide when to use Native AOT. By the end, you'll be equipped to leverage this powerful technology for your next high-performance .NET application.
What is Native AOT?
Native AOT (Ahead-of-Time) compilation is a technology that compiles .NET code directly to native machine code before execution, rather than using a Just-In-Time (JIT) compiler at runtime. This means the entire application, including the runtime, is compiled into a single executable file. The result is a self-contained binary that does not require the .NET runtime to be installed on the target machine. Native AOT is particularly beneficial for scenarios where startup time and memory footprint are critical, such as serverless functions, containers, and command-line tools. However, it comes with limitations: not all .NET features are supported. For example, dynamic code generation via System.Reflection.Emit is not available, and reflection is heavily restricted. The compiler performs aggressive trimming and static analysis to determine which types and members are actually used, so any code that relies on runtime discovery must be explicitly annotated.
Setting Up a Native AOT Project
To get started with Native AOT, you need .NET 8 SDK or later. Create a new console application and modify the project file to enable AOT. The key property is PublishAot, which must be set to true. Additionally, you should set the target runtime (e.g., win-x64, linux-x64) and enable trimming for smaller binaries. Here's a step-by-step guide: 1. Create a new console project: dotnet new console -n AotDemo. 2. Edit the .csproj file to add the necessary properties. 3. Publish the application using the dotnet publish command with the appropriate runtime identifier. The output will be a single executable file. Note that Native AOT is only supported for console applications and ASP.NET Core minimal APIs (experimental). Class libraries cannot be published as AOT directly, but they can be consumed by AOT applications if they are compatible.
Understanding Trimming and Reflection
One of the biggest challenges with Native AOT is reflection. The AOT compiler performs static analysis to determine which types and members are used. If your code uses reflection to access types that are not directly referenced, they may be trimmed away, causing runtime failures. To prevent this, you can use attributes like [DynamicDependency] or [RequiresUnreferencedCode] to inform the linker. Additionally, you can use a linker descriptor XML file to explicitly preserve types. Another approach is to use source generators to generate code at compile time instead of using reflection. For example, System.Text.Json source generator can be used to avoid reflection-based serialization. Let's look at an example where we use [DynamicDependency] to preserve a type used via reflection.
Native AOT with ASP.NET Core Minimal APIs
Starting from .NET 8, ASP.NET Core minimal APIs support Native AOT. This allows you to create high-performance web services with minimal startup time. However, not all ASP.NET Core features are compatible. For example, you cannot use MVC controllers, Razor Pages, or SignalR with AOT. Only minimal APIs are supported. Additionally, you must avoid dynamic features like custom model binding or runtime compilation. To create an AOT-compatible minimal API, you need to use the Microsoft.AspNetCore.App framework and ensure your code is trimmable. Here's an example of a simple minimal API that can be published with AOT.
Performance Benchmarks: JIT vs AOT
Native AOT excels in startup time and memory usage, but may have slightly slower steady-state performance compared to JIT because the JIT can optimize based on runtime profiling. However, for many applications, the difference is negligible. Let's compare a simple 'Hello World' console app published with JIT and AOT. The AOT version starts in under 10ms, while the JIT version takes around 100ms. The binary size for AOT is around 1-2 MB, while the JIT version with runtime is about 50 MB. For a more complex application, the gap narrows but AOT still wins on startup. Memory usage is also lower for AOT because the runtime is compiled into the binary and no JIT code is generated.
Limitations and Compatibility
Not all .NET features work with Native AOT. Here's a list of unsupported features: System.Reflection.Emit (dynamic code generation), COM interop, C++/CLI, runtime code generation (e.g., Expression.Compile), and certain reflection APIs. Additionally, some NuGet packages may not be AOT-compatible. Always check for AOT compatibility warnings when publishing. You can use the dotnet-trimmer tool to analyze your dependencies. Another limitation is that you cannot use AOT for class libraries; only executable projects can be published as AOT. However, you can create an AOT-hosted library by building a native host that loads the library. The table below summarizes the compatibility of common .NET features.
Advanced: Custom Linker Descriptors
When [DynamicDependency] is not enough, you can use a linker descriptor XML file to explicitly preserve types, methods, or assemblies. This is useful for complex scenarios where reflection is used across multiple assemblies. Create an XML file (e.g., LinkerConfig.xml) and set it as a LinkerDescriptor in your project file. The linker descriptor format allows you to specify assemblies, types, and members to keep. Here's an example that preserves all members of a specific assembly.
The 10-Second Startup That Cost $1000
- Cold start is a critical metric for serverless and containerized apps.
- Native AOT can dramatically reduce startup time by eliminating JIT.
- Always profile startup performance in production-like environments.
- Consider Native AOT for latency-sensitive services.
- Test thoroughly because not all libraries support AOT.
dotnet publish -c Release -r win-x64 --self-containeddotnet publish -c Release -r linux-x64 -p:PublishAot=true| File | Command / Code | Purpose |
|---|---|---|
| HelloAot.cs | using System; | What is Native AOT? |
| AotDemo.csproj | Setting Up a Native AOT Project | |
| ReflectionExample.cs | using System; | Understanding Trimming and Reflection |
| Program.cs | var builder = WebApplication.CreateSlimBuilder(args); | Native AOT with ASP.NET Core Minimal APIs |
| Benchmark.cs | using System.Diagnostics; | Performance Benchmarks |
| Unsupported.cs | using System.Reflection.Emit; | Limitations and Compatibility |
| LinkerConfig.xml | Advanced |
Key takeaways
Common mistakes to avoid
3 patternsUsing reflection without preserving types
Assuming all NuGet packages work with AOT
Not testing with AOT publish until late in development
Interview Questions on This Topic
What is Native AOT and how does it differ from JIT compilation?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.
That's C# Advanced. Mark it forged?
3 min read · try the examples if you haven't