Native AOT for Web APIs

Publish a minimal API with Native AOT: CreateSlimBuilder, source-generated JSON, fixing trim warnings, and measuring if the startup and memory gains pay off.

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

Native AOT compiles the app and the parts of the framework it uses into one native executable at publish time. There is no JIT at runtime, so the process starts in milliseconds, uses less memory, and runs on a runtime-deps image without the .NET runtime installed. The price: no runtime code generation, and the trimmer removes everything it cannot prove is used. Reflection over types it cannot see, Reflection.Emit, dynamically loaded assemblies and many libraries built on them do not work.

What changes in the code

  • <PublishAot>true</PublishAot> in the project. It also turns on the AOT and trim analyzers during ordinary builds, so warnings show up in the IDE, and it enables the source generators for minimal API request delegates and configuration binding.
  • WebApplication.CreateSlimBuilder instead of CreateBuilder. It leaves out hosting startup assemblies, IIS integration, the EventLog and Debug log providers, HTTPS and HTTP/3 configuration in Kestrel (add UseKestrelHttpsConfiguration() if Kestrel terminates TLS) and the regex and alpha route constraints.
  • Source-generated JSON: a JsonSerializerContext listing every type you serialise, inserted into TypeInfoResolverChain. Reflection-based serialisation is disabled in an AOT app (JsonSerializer.IsReflectionEnabledByDefault is false), so a type you forgot fails at runtime with an exception naming it.

dotnet new webapiaot creates a project already set up this way.

What works

SupportedNot supported or partial
gRPC, JWT bearer authentication, CORS, health checks, rate limiting, output caching, response compression, static files, WebSocketsMVC controllers, Razor Pages, Blazor Server, session; SignalR and minimal APIs have documented gaps; EF Core's AOT support is limited (precompiled queries are experimental)

Trim and AOT warnings

IL2026 (requires unreferenced code) and IL3050 (requires dynamic code) mean the call may break after publishing. Treat them as errors: an app that publishes with warnings can fail at runtime on a path your tests did not hit. Fix them by switching to a source-generated overload, annotating your own code, or replacing the library. Libraries you own should set IsAotCompatible so their own build reports the problem.

Measuring the decision

Publish both ways and measure in the target container: time to first successful response, working set after warm-up, image size, and requests per second at steady state. AOT wins clearly on startup and memory; steady-state throughput can be equal or slightly lower than the JIT, which optimises hot paths with runtime profile data. That makes AOT a strong choice for scale-to-zero services, serverless functions, many small replicas and sidecars, and a poor one for a large monolith that depends on controllers, EF Core and reflection-heavy libraries. Builds are slower, need the platform's native toolchain (clang on Linux), and do not cross-compile between operating systems: build in a container of the target OS and architecture.

Example

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

When to use it

  • Cut cold start of a scale-to-zero webhook receiver from seconds to tens of milliseconds by publishing it with Native AOT
  • Run 40 small pricing API replicas in less memory by shipping a native executable on runtime-deps chiseled images
  • Fail the CI build when a pull request adds a reflection-based JsonSerializer call to an AOT-published service

More examples

The project file

<code>PublishAot</code> is the switch; the analyzers then run on every build, and promoting <code>IL2026</code> and <code>IL3050</code> to errors keeps new reflection from slipping in. <code>InvariantGlobalization</code> shrinks the binary and lets it run on images without ICU, but only if the API never needs culture-specific formatting or comparisons.

Example · xml
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishAot>true</PublishAot>
    <!-- No ICU in the image: only the invariant culture is available; creating a named culture throws -->
    <InvariantGlobalization>true</InvariantGlobalization>
    <!-- Fail the build instead of shipping code that breaks after trimming -->
    <WarningsAsErrors>$(WarningsAsErrors);IL2026;IL3050</WarningsAsErrors>
    <!-- Report each warning at its call site instead of one per assembly -->
    <TrimmerSingleWarn>false</TrimmerSingleWarn>
  </PropertyGroup>

</Project>

Building in the target OS and shipping on runtime-deps

Native AOT needs <code>clang</code> and <code>zlib1g-dev</code> at publish time and cannot cross-compile between operating systems, so the build stage runs Linux. Only the single executable is copied into the chiseled <code>runtime-deps</code> image, which has no shell and no .NET runtime. Build per architecture: an arm64 image needs <code>-r linux-arm64</code> on an arm64 builder.

Example · 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 \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY Orders.Api.csproj .
RUN dotnet restore -r linux-x64
COPY . .
RUN dotnet publish -c Release -r linux-x64 --no-restore -o /app

FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/Orders.Api .
USER $APP_UID
EXPOSE 8080
ENTRYPOINT ["./Orders.Api"]

Pitfall and fix: a reflection call the trimmer flags

The first method compiles and works under the JIT, which is why these bugs survive until the AOT build. Every <code>JsonSerializer</code> overload that takes a <code>JsonTypeInfo&lt;T&gt;</code> from a generated context is safe. The same pattern applies to configuration binding and minimal API parameters, which the SDK source-generates when <code>PublishAot</code> is set.

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

public sealed record InvoiceIssued(string InvoiceNumber, string CustomerId, decimal Amount, DateTimeOffset IssuedAt);

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(InvoiceIssued))]
public partial class EventsJsonContext : JsonSerializerContext;

public sealed class InvoiceEventWriter(ILogger<InvoiceEventWriter> logger)
{
    // IL2026 + IL3050: uses reflection metadata that the AOT publish removes.
    // In the native binary this throws because reflection-based serialisation is disabled.
    public string SerializeBroken(InvoiceIssued evt) => JsonSerializer.Serialize(evt);

    // Fixed: the generated metadata is compiled in; no warning, works under AOT and JIT.
    public string Serialize(InvoiceIssued evt)
    {
        var json = JsonSerializer.Serialize(evt, EventsJsonContext.Default.InvoiceIssued);
        logger.LogDebug("Serialised invoice event {InvoiceNumber}", evt.InvoiceNumber);
        return json;
    }

    public InvoiceIssued? Deserialize(string json) =>
        JsonSerializer.Deserialize(json, EventsJsonContext.Default.InvoiceIssued);
}

Discussion

  • Be the first to comment on this lesson.