Native AOT in a Nutshell

Decide when to publish an API as Native AOT, what you gain in start-up and memory, and which code and libraries stop working.

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

With <PublishAot>true</PublishAot>, dotnet publish compiles your code and the parts of the framework it uses ahead of time into one native executable. There is no JIT and no IL at run time. To make that possible the trimmer removes everything it cannot prove is reachable, and code generation at run time is unavailable. Setting the property also turns on the trimming and AOT analyzers during normal builds, which report IL2026 (RequiresUnreferencedCode) and IL3050 (RequiresDynamicCode) where your code or a library depends on reflection the compiler cannot see through.

What you gain and what you pay

JIT (default)Native AOT
Start-upHundreds of milliseconds to first responseTens of milliseconds
MemoryJIT, IL and metadata residentNoticeably smaller working set
Imageaspnet:10.0 with the runtimeruntime-deps:10.0, no runtime needed
Peak throughputDynamic PGO tunes hot paths under loadNo run-time profile, often slightly lower
BuildAny OS builds for any OSNative toolchain (clang on Linux), no cross-OS publish, slower

What does not work

  • MVC controllers, Razor Pages and Blazor Server. Minimal APIs are supported through the Request Delegate Generator, which generates binding code at compile time instead of using reflection, with some documented gaps (see Native AOT for Web APIs).
  • Reflection-based JSON. Serializable types must be listed in a JsonSerializerContext; in an AOT publish reflection-based serialization is disabled.
  • Assembly.LoadFrom plugins, Reflection.Emit and libraries that generate proxies at run time.
  • Libraries that scan assemblies or bind by reflection. EF Core's Native AOT support is limited, so data-heavy services usually stay on the JIT. Check a library for trimming warnings before adopting it.

CreateSlimBuilder

WebApplication.CreateSlimBuilder registers less than CreateBuilder: no HTTPS or HTTP/3 in Kestrel, no regex or alpha route constraints, no hosting startup assemblies, and only the console logging provider. That suits a container behind an ingress that terminates TLS. The AOT template uses it; it also works without AOT.

When to choose it

Pick Native AOT for services where cold start or per-replica memory dominates: scale-to-zero APIs, many small replicas, webhook receivers, sidecars and CLI tools. Stay on the JIT for large MVC or EF Core-heavy services, or when a key dependency is not AOT-compatible. PublishAot=true also writes the reflection-disabled JSON switch into the runtimeconfig.json of ordinary builds, so dotnet run of the API fails on a type missing from the JsonSerializerContext just as the native binary does. The gap is in tests: a test host runs with the test project's own runtimeconfig, which does not carry the switch, so the missing type passes every test and the published binary throws "Reflection-based serialization has been disabled" on the first request. Set JsonSerializerIsReflectionEnabledByDefault to false in Directory.Build.props so test projects inherit it. The project file, Dockerfile, trim warnings (IL2026, IL3050) and library fixes for an AOT API are covered in Native AOT for Web APIs.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Cut cold start of a scale-to-zero pricing API so the first request after idle no longer times out at the gateway.
  • Run forty small webhook-receiver replicas in less memory by publishing them as native executables on runtime-deps images.
  • Catch a type missing from the JsonSerializerContext in the integration tests by setting the reflection switch in Directory.Build.props.

More examples

Reflection-based JSON is switched off

The overload without a <code>JsonTypeInfo</code> falls back to reflection. In a project with <code>PublishAot=true</code> the build writes <code>JsonSerializerIsReflectionEnabledByDefault=false</code> into <code>runtimeconfig.json</code>, so that call throws under <code>dotnet run</code> as well as in the native binary. A test project does not inherit the switch unless it comes from <code>Directory.Build.props</code>, which is where the setting belongs.

Example · csharp
using System.Text.Json;
using System.Text.Json.Serialization;

var order = new OrderPlaced(1042, 79.80m);

// Listed in the context: works under the JIT and in the AOT binary alike.
Console.WriteLine(JsonSerializer.Serialize(order, ShopJsonContext.Default.OrderPlaced));

// Not listed: with PublishAot=true this throws NotSupportedException
// ("Reflection-based serialization has been disabled for this application").
try
{
    Console.WriteLine(JsonSerializer.Serialize(new RefundIssued(1042, 7.50m)));
}
catch (NotSupportedException ex)
{
    Console.WriteLine($"Missing from ShopJsonContext: {ex.Message}");
}

record OrderPlaced(int OrderId, decimal Total);
record RefundIssued(int OrderId, decimal Amount);

[JsonSerializable(typeof(OrderPlaced))]
internal partial class ShopJsonContext : JsonSerializerContext;

Replacing reflection-based activation

Code that names its types statically is visible to the trimmer, so both gateways survive the publish and an unknown provider fails with a clear message at start-up. The same rewrite applies to plugin folders and assembly scanning: list the implementations explicitly. In a real AOT build <code>CaptureResult</code> would also be added to the app's <code>JsonSerializerContext</code>.

Example · csharp
var builder = WebApplication.CreateSlimBuilder(args);

// Before: Activator.CreateInstance(Type.GetType(config["Payments:Provider"]!)!) loads a gateway by name.
// The trimmer cannot see that type, removes it, and the published binary fails at start-up.
var provider = builder.Configuration["Payments:Provider"] ?? "stripe";
builder.Services.AddSingleton<IPaymentGateway>(_ => provider switch
{
    "stripe" => new StripeGateway(),
    "adyen" => new AdyenGateway(),
    _ => throw new InvalidOperationException($"Unknown payment provider '{provider}'"),
});

var app = builder.Build();
// Real capture endpoints require authorization (.RequireAuthorization()) and an Idempotency-Key;
// omitted here to keep the AOT point focused.
app.MapPost("/payments/{orderId:int}/capture", async (int orderId, IPaymentGateway gateway, CancellationToken ct) =>
    TypedResults.Ok(new CaptureResult(orderId, await gateway.CaptureAsync(orderId, ct))));
app.Run();

interface IPaymentGateway
{
    Task<string> CaptureAsync(int orderId, CancellationToken ct);
}

sealed class StripeGateway : IPaymentGateway
{
    public Task<string> CaptureAsync(int orderId, CancellationToken ct) => Task.FromResult($"stripe-capture-{orderId}");
}

sealed class AdyenGateway : IPaymentGateway
{
    public Task<string> CaptureAsync(int orderId, CancellationToken ct) => Task.FromResult($"adyen-capture-{orderId}");
}

record CaptureResult(int OrderId, string CaptureId);

Making the tests behave like the AOT build

A <code>WebApplicationFactory</code> test runs the API inside the test project's process, with the test project's <code>runtimeconfig.json</code>. Set only through the API's <code>PublishAot</code>, the switch never reaches that file and a type missing from the <code>JsonSerializerContext</code> is quietly served by reflection in every test. Putting it here is the same pattern Small, Hardened Images uses for <code>InvariantGlobalization</code>.

Example · xml
<!-- Directory.Build.props at the repository root: the API and its test projects all import it.
     The API's own .csproj sets <PublishAot>true</PublishAot>. -->
<Project>
  <PropertyGroup>
    <!-- PublishAot already sets this for the API; test projects need it from here -->
    <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
  </PropertyGroup>
</Project>

Discussion

  • Be the first to comment on this lesson.