Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 077 / 100

Native AOT for .NET APIs: a hands-on guide for engineers

Millisecond startup and tiny memory for the right workloads.

Expona Admin7 min read

Native AOT for .NET APIs: Millisecond Startup and Tiny Memory for the Right Workloads

If you've ever deployed a .NET service to AWS Lambda and watched it time out on cold start, or spun up a container only to see 200MB of memory consumed before serving a single request, Native AOT for .NET APIs might be exactly what you need. It compiles your entire application ahead of time into native machine code, eliminating the Just-In-Time (JIT) compiler and the .NET runtime overhead. The trade-off is real though: no runtime reflection, stricter compile-time checks, and library compatibility issues that can blindside you at release.

Over 5+ years building production platforms—from e-commerce sites handling thousands of concurrent users to trading systems where milliseconds matter—I've learned that Native AOT is not a silver bullet. It's a precision tool for specific workloads. Let's dig into when to use it, what breaks, and how to ship it safely.

Ideal for Serverless, CLIs, and Small Microservices

Native AOT shines when cold start time and memory footprint are your primary constraints. Picture this: a Lambda function that processes SQS messages, transforms data, and pushes it to a database. With the traditional .NET runtime, that function spends 2–4 seconds just initializing before it touches your business logic. With Native AOT, you're looking at 100–300 milliseconds. For a high-volume async queue, that's the difference between handling 100 messages per minute and 500 messages per minute on the same infrastructure cost.

I built a ride-hailing platform's location aggregation service—a small microservice that batches GPS coordinates and writes them to Redis. We were paying for container overhead we didn't need. Switching to Native AOT cut our per-instance memory from 180MB to 45MB and startup from 1.2 seconds to 0.15 seconds. That sounds marginal until you're running 50 instances and your cloud bill shrinks by 30%.

Command-line tools are another natural fit. Imagine a database migration CLI or a batch processing tool that runs once per hour. With Native AOT, users see instant execution instead of waiting for the runtime to warm up. No more frustrated DevOps engineers asking why a 50-line script takes 3 seconds to start.

The constraint is size: Native AOT works best for services with a focused responsibility. A large monolith with 20 dependencies might struggle with AOT compatibility. A single-purpose API that validates webhooks, transforms them, and publishes events? That's AOT territory.

No Runtime Reflection—Some Libraries Do Not Support It

This is where Native AOT bites you hardest. When you compile ahead of time, the compiler must know every type, method, and property your code will use at runtime. It can't dynamically load assemblies or inspect types on demand. Many popular libraries rely on reflection by default: ORM mappers scanning entity properties, serializers building metadata at runtime, dependency injection containers wiring up types through attributes.

I once tried moving our travel booking microservice to Native AOT. We were using an older version of Entity Framework that reflected over entity types at startup. The compiler couldn't prove those types were used, so it stripped them. The binary compiled fine. It crashed at runtime with a null reference on the first query. The fix: upgrade EF Core to a version with source-generated queries, or hand-code the reflection metadata in an RD.XML file (trimming directives that tell the AOT compiler what to preserve).

Here's a concrete example: a simple API that deserializes JSON and inserts records.

csharp
// Without source generation: works in JIT, breaks in AOT
public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; }
    public decimal Total { get; set; }
}

var json = "{\"id\":1,\"customerName\":\"Alice\",\"total\":99.99}";
var order = JsonConvert.DeserializeObject<Order>(json); // Reflection at runtime

When compiled as Native AOT, the JSON serializer can't reflect over the Order type. It fails. The solution: use System.Text.Json with source-generated serialization context.

csharp
// With source generation: works in JIT and AOT
[JsonSerializable(typeof(Order))]
public partial class OrderJsonContext : JsonSerializerContext { }

var json = "{\"id\":1,\"customerName\":\"Alice\",\"total\":99.99}";
var order = JsonSerializer.Deserialize<Order>(json, OrderJsonContext.Default.Order);

The difference is small in the code, massive in the runtime. The compiler now generates serialization logic at build time. No reflection needed.

Check your dependency tree ruthlessly. Libraries like AutoMapper (without source generation plugins), older Newtonsoft.Json versions, and some DI containers will break silently or noisily. The .NET team maintains a compatibility list, but test locally before betting your production deploy on it.

Use Source-Generated JSON

System.Text.Json with source generation is non-negotiable for AOT APIs. Newtonsoft.Json (Json.NET) will not work reliably—its core design depends on runtime reflection. I learned this the hard way when our CRM microservice tried to serialize complex nested objects with custom converters. AOT compilation succeeded, runtime deserialization failed on the first request.

Source generation means the compiler runs a Roslyn analyzer at build time, inspects your types, and generates serialization/deserialization code. Zero runtime reflection, zero overhead, zero surprises.

Here's how to set it up in a minimal API:

csharp
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

// Register the source-generated context
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.AddContext<AppJsonContext>();
});

var app = builder.Build();

app.MapPost("/orders", (CreateOrderRequest req, OrderService service) =>
{
    var order = service.Create(req);
    return Results.Created($"/orders/{order.Id}", order);
});

app.Run();

// Define your types and serialization context
public record CreateOrderRequest(string CustomerName, decimal Total);
public record Order(int Id, string CustomerName, decimal Total);

[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(Order))]
public partial class AppJsonContext : JsonSerializerContext { }

The [JsonSerializable] attributes tell the compiler: generate serialization code for these types. When your API runs, there's no reflection. No metadata scanning. Just fast, predictable serialization.

For APIs with dozens of types, this scales nicely. I've built a SaaS platform's webhook dispatcher with 40+ event types, all source-generated. The binary is smaller, memory is lower, and JSON handling is bulletproof at scale.

Test AOT Compatibility Early, Not at Release

The worst time to discover an AOT incompatibility is in production. I've seen teams spend weeks debugging why a service works perfectly in development but crashes on Lambda after AOT compilation. The solution: test AOT compatibility as part of your regular build and test pipeline, not as an afterthought.

The simplest approach: publish your app with the PublishAot property enabled, and run your integration tests against the AOT-compiled binary.

csharp
// In your .csproj
<PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <PublishAot>true</PublishAot>
    <TrimMode>full</TrimMode>
</PropertyGroup>

Then publish and test:

csharp
dotnet publish -c Release
// Run your integration tests against ./bin/Release/net8.0/publish/YourApp

If reflection sneaks in anywhere—a library loading a plugin, a config deserializer, a feature flag checker—your AOT tests will fail before you ship. This is not optional; it's insurance.

I also recommend using the TrimmerRootAssembly and RD.XML (Runtime Directives) files for edge cases where you need to preserve types. Document why. It's a code smell that should trigger a library upgrade or architecture review.

Common Mistakes

  • Publishing with PublishAot but not testing the binary: Your local build passes, the publish succeeds, but the output won't run. Always test the published artifact against real scenarios.
  • Ignoring trimming warnings: The compiler warns you when it can't prove a type is used. Don't ignore these—they often predict runtime failures. Fix the root cause (usually a reflection call or reflection-heavy library) instead of suppressing warnings.
  • Mixing reflection and AOT: If you have a factory pattern that uses Type.GetType() or Activator.CreateInstance(), AOT won't work without explicit trimming directives. Refactor to use source generation or dependency injection instead.
  • Assuming all .NET libraries support AOT: They don't. Check the library's documentation and GitHub issues. If it's not AOT-ready, either upgrade, contribute a fix, or abandon AOT for that service.
  • Deploying AOT to non-constrained environments: If your API runs on a large VM with plenty of CPU and memory, AOT might not be worth the complexity. Save it for Lambda, edge functions, or resource-constrained deployments.

Key Takeaways

  • Native AOT for .NET APIs trades flexibility for speed and memory. Use it when cold start and footprint matter: serverless, CLIs, small microservices.
  • Reflection is your enemy in AOT. Eliminate it with source-generated JSON, source-generated queries (EF Core), and constructor-based dependency injection.
  • System.Text.Json with source generation is mandatory. Newtonsoft.Json will not reliably work with Native AOT.
  • Test AOT compatibility as part of your CI/CD pipeline, not after release. Publish with PublishAot enabled and run integration tests against the compiled binary.
  • Library compatibility is hit-or-miss. Audit your dependencies early. Outdated or reflection-heavy libraries will force you to upgrade or switch.
  • Don't use AOT everywhere. It's a precision tool for constrained environments. A large monolith on a well-resourced server doesn't benefit.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles