Oğuzhan KARAGÜZEL

OĞUZHAN KARAGÜZEL

Software Developer

JIT vs AOT: Compiling a C# Project with Native AOT

Author: Oğuzhan KARAGÜZEL ·

For many years C# applications ran in one way: code is compiled to an intermediate language, and while the application runs, the JIT compiler turns it into machine code. Native AOT, introduced in .NET 7, changed that; a C# project can now run as a single file on a machine with no .NET runtime installed. In this article I explain the difference between JIT and AOT, the three compilation modes in .NET, how to compile a project with Native AOT step by step, and where AOT doesn't fit, with my own measurements.

JIT: compiling while running

The C# compiler doesn't turn your code directly into machine code, but into a platform-independent intermediate language (IL). That IL is what your .dll files contain. When the application runs, the .NET runtime (CLR) compiles each method, the moment it is first called, into machine code for that machine's processor, using the JIT (Just-In-Time) compiler.

In modern .NET the JIT does this in tiers:

  1. Tier 0: when a method is first called it is compiled quickly, with few optimisations. The goal is to get the application running as soon as possible.
  2. Profiling: frequently called methods are watched; which branches run and which types arrive are recorded (dynamic PGO).
  3. Tier 1: hot methods are recompiled in the background, much more heavily optimised, using that information.

The strength of this approach is that code is optimised for real usage: the JIT decides by watching how your application actually runs on that machine. The weakness is startup: every method's first call carries a compilation cost, and the JIT itself takes memory.

AOT: compiling ahead of time

AOT (Ahead-Of-Time) means compiling before the application runs, at publish time. .NET has two forms of it:

  • ReadyToRun: precompiled machine code is added next to the IL. The application still runs on the normal .NET runtime; the JIT is still there and steps in to compile hot code better. It is a kind of "head start". I cover it in detail in ReadyToRun and Cold Starts.
  • Native AOT: the whole application, together with the .NET libraries it uses, is compiled into a single native executable. There is no JIT and no IL at run time, and the machine doesn't need .NET installed. It resembles a program compiled with Go or Rust.

The three approaches compared

AspectJITReadyToRunNative AOT
Startup timeSlowestFaster (in code-heavy apps)Very fast
Memory useHighSimilar to JITLow
Long-running peak speedVery good (dynamic PGO)Very good (hot code is recompiled)Good, but can't be optimised with run-time information
Needs .NET on the machine?YesYesNo
Reflection and dynamic codeFull supportFull supportLimited; code not seen at compile time doesn't exist
PlatformThe same file everywherePlatform-specificPlatform-specific, built on the same OS

I measured it: startup, memory and size

To see the difference, I wrote a small minimal API with two endpoints and compiled it three ways. I started each 15 times and measured the time from starting the process to the first successful HTTP response. Environment: a Linux container on Docker Desktop (WSL2), limited to 2 cores and 2 GB of memory, .NET 10.

BuildFirst response (median)Memory (RSS)Publish
JIT195 ms62 MB152 KB (+ .NET on the server)
ReadyToRun195 ms63 MB168 KB (+ .NET on the server)
Native AOT23 ms17 MB9.5 MB single file, no .NET needed

Native AOT made the first response about 8 times faster and cut memory to a quarter. Don't be surprised that ReadyToRun gained nothing here: in an application this small, almost all the code that runs comes from the ASP.NET framework, which is already precompiled. I didn't measure peak requests per second for a service under sustained load; there, the optimisations the JIT makes with run-time information can close the gap or even pull ahead.

Compiling with Native AOT, step by step

1. Prerequisites. Native AOT uses the platform's native linker in its last step:

  • Windows: the "Desktop development with C++" workload in Visual Studio (the MSVC build tools).
  • Linux: the clang and zlib1g-dev packages (Ubuntu/Debian).

An important limitation: Native AOT doesn't cross-compile between operating systems. You can't build for your Linux server on Windows; you have to build on Linux or in a Linux container.

2. Turn on AOT in the project. One setting is enough:

XML
<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>

With PublishAot on, analyzers for AOT-incompatible code also kick in and report problems as warnings at build time. InvariantGlobalization is optional; if you don't need culture-specific formatting, it makes the file smaller.

3. Write AOT-friendly code. The most common change is JSON. By default System.Text.Json inspects types with reflection at run time; with AOT, a source generator is used instead:

C#
using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(o =>
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

var app = builder.Build();

Todo[] todos = [new(1, "Finish the article", false), new(2, "Prepare the cover", true)];

app.MapGet("/todos", () => todos);
app.MapGet("/todos/{id:int}", (int id) =>
    todos.FirstOrDefault(t => t.Id == id) is { } todo ? Results.Ok(todo) : Results.NotFound());

app.Run();

public record Todo(int Id, string Title, bool Done);

[JsonSerializable(typeof(Todo[]))]
[JsonSerializable(typeof(Todo))]
internal partial class AppJsonContext : JsonSerializerContext;

CreateSlimBuilder is a lighter start that leaves out features that aren't AOT-friendly or that most services don't need. If you're starting from scratch, the dotnet new webapiaot template comes with all of these settings.

4. Publish. With the target platform:

Bash
dotnet publish -c Release -r linux-x64

The output folder holds a single executable without an extension and a .dbg file with debug symbols. Copying just the executable to the server is enough.

5. Build in Docker. If you work on Windows and deploy to a Linux server, the easiest way is to build in a container. As the runtime image, the very small runtime-deps image, which contains no .NET, is enough:

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
RUN apt-get update && apt-get install -y --no-install-recommends clang zlib1g-dev
WORKDIR /src
COPY . .
RUN dotnet publish -c Release -r linux-x64 -o /app

FROM mcr.microsoft.com/dotnet/runtime-deps:10.0
WORKDIR /app
COPY --from=build /app/Bench .
ENTRYPOINT ["./Bench"]

Why the warnings matter: a real crash

Native AOT compiles the code it can see at compile time. Types found with reflection at run time, assemblies loaded dynamically or code generated at run time (Reflection.Emit) are things the compiler can't see. The analyzers report these risks as warnings such as IL2026 and IL3050. These warnings don't mean "maybe"; they mean "may crash at run time".

To show this, I compiled a small MVC application using a Razor view and EF Core with Native AOT. The build finished successfully, with many warnings from the MVC and EF Core assemblies, and produced an executable. When I ran it, the application crashed before it could answer a single request:

Output
warning IL2026: Microsoft.AspNetCore.Mvc.ModelBinding...: Using member '...' which has
  'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code.
warning IL3053: Assembly 'Microsoft.EntityFrameworkCore' produced AOT analysis warnings.
warning IL3053: Assembly 'Microsoft.AspNetCore.Mvc.Razor' produced AOT analysis warnings.

$ ./Shop
Unhandled exception. System.TypeLoadException: Could not resolve type
'Microsoft.AspNetCore.Mvc.ApplicationParts.ConsolidatedAssemblyApplicationPartFactory'
in assembly 'Microsoft.AspNetCore.Mvc.Razor'.

MVC finds its application parts at startup by type name, through reflection; since the AOT compiler saw no direct reference to that type, it never put it in the file. The rule is simple: don't ship a project that produces AOT warnings. If a warning can't be resolved, that project isn't suitable for Native AOT.

Where Native AOT fits, and where it doesn't

Where it fits:

  • Command-line tools: they open instantly, and the user's machine doesn't need .NET.
  • Serverless functions and containers that scale to zero: cold starts are felt directly by users.
  • Small microservices written with minimal APIs or gRPC: less memory, smaller images, more instances on the same server.

Where it doesn't (as of today):

  • ASP.NET Core MVC and Razor Pages, Blazor Server: as the example above shows, they rely on reflection.
  • Applications heavily dependent on EF Core: its AOT support is limited and experimental, and requires precompiled models and queries.
  • Applications that load plugins, compile at run time, or rely on libraries that use reflection heavily.

That is why Elevare, the platform this site runs on, uses ReadyToRun instead of Native AOT: for an application built on MVC, Razor and EF Core, it is the safe way to speed up startup.

Conclusion

The JIT compiles code while it runs, according to real usage; it is flexible and its long-running performance is very good, but it pays a price at startup. ReadyToRun reduces that price and keeps the JIT's flexibility. Native AOT removes the JIT entirely: in my measurement, the first response dropped from 195 ms to 23 ms and memory from 62 MB to 17 MB. In return you have to give up reflection and dynamic code. For small services and tools, Native AOT is the right choice; for MVC and EF Core heavy applications, ReadyToRun. Whichever you choose, take the analyzer warnings seriously, and always measure.