DEV Community

Khaja Hussain
Khaja Hussain

Posted on

.NET Native AOT vs Traditional ASP.NET Core: What Developers Should Know

Recently, I was checking the memory usage of a server running several ASP.NET Core applications.

Nothing had crashed. The applications were running fine. But memory utilization was higher than I would have liked, and it got me thinking about whether I could make better use of the server's resources.

I've been working with C# and ASP.NET for years, mostly building APIs, database-driven applications, and Angular frontends. My usual stack is ASP.NET Core, SQL Server, and Entity Framework Core.

So naturally, I started looking at .NET Native AOT.

I'd heard about the smaller executables, faster startup times, and lower memory requirements. All of that sounded promising.

But I had a more practical question:

Could I take an existing ASP.NET Core application, enable Native AOT, and get the same functionality while using fewer resources?

The more I looked into it, the more I realized that the answer depends heavily on how the application is built.

First, what actually changes with Native AOT?

In a traditional ASP.NET Core application, our C# code is compiled into Intermediate Language (IL).

When the application runs, the .NET runtime uses Just-in-Time (JIT) compilation to turn that IL into native machine code as needed.

Native AOT changes when that compilation happens.

Instead of waiting until runtime, the application is compiled into native machine code during publishing.

That means the deployed application doesn't need a JIT compiler to execute its code.

It can also run without a separately installed .NET runtime because the necessary runtime components are included in the published application.

For certain workloads, especially small services that start frequently, this can make a noticeable difference.

But there's something worth understanding here.

Native AOT doesn't remove garbage collection from .NET.

We're still writing managed C#. Objects still get allocated, and the garbage collector still manages memory.

So if an application is holding a large collection in memory, caching too much data, or loading unnecessary records from SQL Server, Native AOT isn't going to magically fix that.

This was an important distinction for me because memory usage was what got me interested in AOT in the first place.

Where Native AOT starts getting complicated

Creating a new Minimal API with Native AOT support is fairly straightforward.

The interesting part is what happens when we try to apply the same approach to an existing business application.

Consider a typical ASP.NET Core project.

It probably has controllers, services, dependency injection, Entity Framework Core, authentication, logging, and a few third-party packages.

For a traditional ASP.NET Core application, that's a completely normal setup.

For Native AOT, some of those choices become important.

1. What about my existing controllers?

This was the first major limitation that caught my attention.

As of .NET 10, ASP.NET Core MVC controllers aren't supported by Native AOT.

Minimal APIs are supported, although there are still compatibility limitations.

If your application has dozens of controllers, you can't simply add PublishAot to the project file and expect everything to work.

You may have to restructure parts of the application.

That isn't necessarily a bad thing for a new microservice, but it's a different conversation when you're maintaining an existing enterprise application.

2. Entity Framework Core is another consideration

I use Entity Framework Core extensively, especially with SQL Server.

It's convenient for filtering, pagination, relationships, and working with database entities.

However, Native AOT support in EF Core is still experimental according to Microsoft's documentation.

One of the challenges is query generation.

In a traditional EF Core application, queries can be built dynamically at runtime.

For example:

var query = dbContext.Products.AsQueryable();

if (!string.IsNullOrEmpty(search))
{
    query = query.Where(x => x.Name.Contains(search));
}

if (categoryId.HasValue)
{
    query = query.Where(x => x.CategoryId == categoryId.Value);
}

var products = await query
    .OrderBy(x => x.Id)
    .Skip(pageIndex * pageSize)
    .Take(pageSize)
    .ToListAsync();
Enter fullscreen mode Exit fullscreen mode

This is a common pattern in business applications.

Filtering and pagination requirements change constantly, and developers need the flexibility to construct queries based on user input.

EF Core's experimental Native AOT support uses query precompilation, which has limitations with dynamically composed queries like this.

You can sometimes rewrite queries into shapes that can be precompiled, but that adds design constraints.

For a small API with a handful of predictable queries, that might be manageable.

For a large application with complicated search screens and reporting requirements, it's worth thinking carefully before committing to that approach.

Trying Native AOT with a small API

If you're curious about Native AOT, I wouldn't recommend starting by converting a large existing application.

I'd start with a small API.

With the .NET 10 SDK installed, you can create a project using Microsoft's AOT template:

dotnet new webapiaot -n AotDemo
cd AotDemo
Enter fullscreen mode Exit fullscreen mode

The template is already configured for Native AOT and uses Minimal APIs.

One difference you'll notice is the way JSON serialization is configured.

Let's say we have a simple endpoint returning orders.

using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(
        0, AppJsonSerializerContext.Default);
});

var app = builder.Build();

app.MapGet("/orders", () => new[]
{
    new OrderDto(101, "Paid", 49.99m),
    new OrderDto(102, "Pending", 19.50m)
});

app.Run();

public record OrderDto(int Id, string Status, decimal Total);

[JsonSerializable(typeof(OrderDto[]))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}
Enter fullscreen mode Exit fullscreen mode

Notice the JsonSerializable attribute.

In a regular ASP.NET Core application, JSON serialization often relies on runtime reflection to discover the properties of objects.

Native AOT works better when that information is generated during compilation.

That's why System.Text.Json source generation is important.

The API response itself is ordinary JSON:

[
  {
    "id": 101,
    "status": "Paid",
    "total": 49.99
  },
  {
    "id": 102,
    "status": "Pending",
    "total": 19.50
  }
]
Enter fullscreen mode Exit fullscreen mode

While experimenting with different API implementations, I would also compare their actual responses.

It's easy to focus on startup time or memory usage and overlook whether a change to serialization has affected the response structure.

For larger sample responses, especially arrays containing nested objects, a table view can help. JSON to Table is a browser-based tool I built for viewing JSON in a more readable format.

Of course, I would only use sample or non-sensitive data with an online tool. For actual validation, automated tests should compare the expected response structure and values.

Once the application is ready, you can publish it with Native AOT.

For example, on a properly configured Windows development machine:

dotnet publish -c Release -r win-x64
Enter fullscreen mode Exit fullscreen mode

Native AOT publishing requires platform-specific build tools, including the appropriate C++ compiler toolchain.

Also, remember that running the project with dotnet run doesn't test the final Native AOT executable. The real test is the published binary.

Would Native AOT make every API faster?

Not necessarily.

This is where I think developers need to separate startup performance from request-processing performance.

Native AOT can significantly improve startup time and reduce application footprint.

But that doesn't automatically mean an API processing thousands of requests will have better throughput.

Suppose an endpoint spends most of its time executing a SQL Server query.

If that query takes 300 milliseconds, switching from JIT to AOT won't suddenly make the database return results in 30 milliseconds.

Database indexes, query execution plans, network latency, caching, and application logic still matter.

Also, the traditional .NET JIT compiler is highly optimized. Once an application has warmed up, it can deliver excellent performance.

So I wouldn't make a migration decision based only on a comparison of two empty API templates.

I'd want to see how my actual application behaves.

How I would compare the two approaches

Before considering Native AOT for production, I'd build a small service that represents part of my existing workload.

Ideally, I'd publish the same functionality with both JIT and Native AOT, using comparable deployment configurations.

I'd measure:

  • Startup time: How quickly can the application begin serving requests?
  • Memory usage: What is the process memory consumption when idle and under a representative workload?
  • API latency: How do average and p95/p99 response times compare?
  • Throughput: How many requests can each version handle before latency starts increasing?
  • Deployment size: How much space does each published application require?
  • Compatibility: Did I have to rewrite code or replace any libraries?

I'd also run the tests multiple times on the same hardware and check the published AOT application's behavior, not just its build output.

If the API works with both approaches and AOT gives me a measurable benefit, great.

But if I have to spend days rewriting controllers or working around library limitations to save a small amount of memory, I would question whether the trade-off makes sense.

Where I think Native AOT fits best

For me, Native AOT is particularly interesting for small, focused services.

A background worker, a lightweight API, or a service that needs to start quickly could be a good candidate.

An application with hundreds of controllers, extensive EF Core usage, and several reflection-heavy libraries is a different story.

In that situation, I'd first look at the existing application's memory allocations, database queries, caching strategy, and hosting configuration.

Sometimes the most effective optimization isn't changing the compilation model. It's fixing an inefficient query or reducing the amount of data the application keeps in memory.

Final thoughts

I started looking into Native AOT because I wanted to understand whether it could help reduce the resources consumed by my ASP.NET Core applications.

I haven't migrated those applications to Native AOT, and I don't have production benchmarks to share yet.

What I've learned so far is that Native AOT is worth exploring, but it shouldn't be treated as an automatic upgrade for every existing .NET application.

If I were starting a small, independent API today, I would seriously consider testing Native AOT from the beginning.

For an established ASP.NET Core application built around controllers and Entity Framework Core, I would be more cautious.

The deciding factor shouldn't be which approach looks faster in a benchmark screenshot.

It should be whether the performance improvement justifies the architectural and maintenance cost.

I'm curious how other .NET developers are approaching this. Have you used Native AOT in production, or are you still sticking with traditional ASP.NET Core for your APIs?


References

Top comments (0)