Performance and Native AOT

ASP.NET Core · Expert · 15 min

Throughput, memory and cold start directly drive hosting costs and user-perceived latency.

ASP.NET Core is among the fastest mainstream frameworks, but app code usually dominates. The main levers:

  • Never block on async code (.Result, .Wait(), synchronous I/O). Blocking starves the thread pool and causes latency spikes under load ("thread pool starvation"). Synchronous body reads are disallowed by default for this reason.
  • Use IHttpClientFactory (AddHttpClient<T>) for outbound calls: pooled handlers, DNS refresh, and resilience via Microsoft.Extensions.Http.Resilience (AddStandardResilienceHandler).
  • Stream large responses (IAsyncEnumerable<T> is serialized as it's produced) instead of buffering whole lists.
  • Reduce allocations in hot paths; check with dotnet-counters (allocation rate, GC, thread pool queue length) and dotnet-trace.
  • Paginate and project database queries; most "slow API" problems are queries.

Native AOT compiles the app to a native executable: startup in tens of milliseconds and much lower memory, great for serverless and dense container hosting. It's supported for minimal APIs, gRPC and worker services, not MVC or Razor. Start with dotnet new webapiaot or set <PublishAot>true</PublishAot>, use WebApplication.CreateSlimBuilder (fewer default features), and register a JSON source-generated context so serialization needs no reflection. The Request Delegate Generator turns your Map* handlers into compile-time code automatically when AOT is enabled.

Fix every AOT/trim warning: they predict runtime failures.

Example

// <PublishAot>true</PublishAot> in the csproj; dotnet publish -r linux-x64
using System.Text.Json.Serialization;

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

app.MapGet("/todos", () => Stream(1000));          // streamed JSON array
app.MapGet("/todos/{id:int}", (int id) => new Todo(id, $"Todo {id}", false));
app.Run();
static async IAsyncEnumerable<Todo> Stream(int n)
{
    for (int i = 1; i <= n; i++)
    {
        if (i % 100 == 0) await Task.Yield();       // e.g. awaiting DB pages
        yield return new Todo(i, $"Todo {i}", i % 2 == 0);
    }
}

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

[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(IAsyncEnumerable<Todo>))]
internal partial class AppJson : JsonSerializerContext { }

Remember

  • Blocking on async code causes thread pool starvation under load
  • Use IHttpClientFactory and stream large responses
  • Native AOT works with minimal APIs: CreateSlimBuilder + JSON source generation
  • Treat trim and AOT warnings as errors

Watch out for

  • Calling .Result on HttpClient calls inside request handlers, which looks fine in tests and collapses under load.

Check yourself

  1. Why does Native AOT need a JsonSerializerContext?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: learn.microsoft.com/en-us/aspnet/core/fundamentals/native-aot