Vergleich zweier .NET-Startvorgänge: eine klassische App mit spürbarer JIT-Warmlaufzeit neben einer Native-AOT-App, die sofort startet, daneben ein sinkendes Cloud-Kosten-Symbol
Zurück zum Blog
.NETNative AOTPerformance

Native AOT in .NET: Schneller starten, weniger Cloud-Kosten zahlen

Sascha KieferEntwicklung

Native AOT compiliert eine .NET-App komplett zu nativem Code, bevor sie überhaupt läuft: kein JIT, keine Warmlaufzeit. Seit dem 1. August 2025 rechnet AWS Lambda die Init-Phase von Zip-Funktionen wie ganz normale Ausführungszeit ab, aus einem reinen Latenzproblem wird plötzlich ein Kostenposten. Wir schauen, was Native AOT technisch macht, wann sich der Umstieg lohnt, wo er schlicht nicht geht, und wie sich der schnellere Start konkret auf deine Cloud-Rechnung auswirkt.

Ein .NET-Prozess, der klassisch startet, macht vor der ersten beantworteten Anfrage eine ganze Menge Arbeit: Assemblies laden, Metadaten lesen, Typen auflösen, und dann Methode für Methode gerade rechtzeitig (just-in-time, JIT) in nativen Code übersetzen. Bei einer lange laufenden App fällt das kaum auf. Bei einer Lambda-Funktion, die pro Aufruf einen neuen Container hochfährt, oder einem Kubernetes-Pod, der unter Lastspitzen in Sekunden bereit sein soll, ist genau diese Anlaufzeit das, was zählt.

Und seit dem 1. August 2025 ist sie nicht mehr nur ein Latenzproblem. AWS hat an diesem Tag die Abrechnung umgestellt: Die Init-Phase von als Zip paketierten Lambda-Funktionen mit verwalteter Runtime wird jetzt genauso abgerechnet wie die eigentliche Ausführungszeit. Vorher war sie das nur bei Custom Runtimes, Provisioned Concurrency oder Container-Images. Ein langsamer Kaltstart kostet jetzt buchstäblich Geld, nicht nur gefühlte Wartezeit.

Genau hier setzt Native AOT an. Schauen wir uns an, was es technisch macht, wo es sich lohnt, wo es schlicht nicht geht, und was das für deine Cloud-Rechnung bedeutet.

Was Native AOT technisch macht

Im normalen .NET-Modell übersetzt dotnet publish deinen Code in Zwischensprache (IL). Beim Start lädt die Runtime diese IL, und der JIT-Compiler übersetzt jede Methode beim ersten Aufruf in nativen Maschinencode. Das ist der Grund, warum .NET-Apps so flexibel sind: Reflection, dynamisches Laden von Assemblies, Codegenerierung zur Laufzeit, all das funktioniert, weil die Runtime bis zuletzt weiß, was im Code steckt.

Native AOT dreht das um. Mit <PublishAot>true</PublishAot> im Projekt und dotnet publish -r <RID> läuft beim Publish ein IL-Compiler (ILC), der deinen gesamten Code samt aller referenzierten Bibliotheken vorab zu nativem Code übersetzt. Das Ergebnis ist eine einzelne, plattformspezifische ausführbare Datei mit einer stark abgespeckten Runtime (inklusive Garbage Collector) eingebettet. Kein JIT läuft mehr zur Laufzeit, weil es keinen mehr gibt.

Damit das funktioniert, muss beim Publish alles statisch bekannt sein. Ungenutzter Code wird per Trimming entfernt, und alles, was zur Laufzeit dynamisch entscheiden würde, welcher Code überhaupt existiert, ist ausgeschlossen. Das ist kein Implementierungsdetail, sondern die zentrale Einschränkung, um die sich fast alles andere in diesem Artikel dreht.

Schneller, aber nicht in jeder Hinsicht besser

Die Microsoft-Dokumentation ist bei den Zahlen bewusst vage, weil die Werte stark vom jeweiligen Workload abhängen, nennt aber drei Effekte durchgängig: kleinere Deployment-Größe, kürzere Startzeit, geringerer Speicherbedarf. In der Praxis kursieren dazu Größenordnungen wie "etwa dreimal schnellerer Start bei weniger als halbem Speicherverbrauch", und einzelne Teams berichten von konkreten Vorher-Nachher-Werten wie 70 ms auf rund 14 ms für eine einfache API. Nimm solche Zahlen als Richtung, nicht als Garantie für deinen Fall, sie hängen stark davon ab, wie viel deine App beim Start tatsächlich lädt und initialisiert.

Wichtig ist die Kehrseite: Native AOT ist eine Optimierung für Start, nicht automatisch für Durchsatz. Der JIT kann zur Laufzeit anhand tatsächlicher Nutzung optimieren (Tiered Compilation, PGO), Native AOT legt den Code beim Publish fest. Ein Detail, das das konkret zeigt: System.Linq.Expressions läuft unter Native AOT immer im interpretierten Modus, spürbar langsamer als der zur Laufzeit generierte Code im JIT-Modell. Für eine kurzlebige Funktion ist das egal, für einen Dauerbetrieb-Service mit hohem Durchsatz kann es das genaue Gegenteil von dem sein, was du willst.

Wann sich Native AOT lohnt

  • Serverless-Funktionen. AWS Lambda (Custom Runtime mit einer Native-AOT-Binary) und Azure Functions (Isolated Worker mit AOT-Unterstützung) profitieren am direktesten, weil dort jeder Kaltstart einzeln zählt, seit August 2025 auch finanziell.
  • Kubernetes mit aggressivem Autoscaling oder Scale-to-Zero. KEDA, Knative oder eigene HPA-Konfigurationen, die Pods aus dem Nichts hochfahren, profitieren von kurzer Time-to-Ready genauso wie Serverless-Funktionen.
  • CLI-Tools und Sidecars. Ein Kommandozeilen-Tool, das hundertmal am Tag für Sekundenbruchteile läuft, spürt jede eingesparte Millisekunde Startzeit direkt, ganz ohne Cloud-Kontext.
  • Hochdichte-Deployments. Viele kleine Service-Instanzen auf gemeinsamer Infrastruktur, wo geringerer Speicherbedarf pro Instanz direkt mehr Instanzen pro Knoten bedeutet.
  • Plattformen, auf denen JIT gar nicht erlaubt ist. Auf iOS und tvOS verbietet das Betriebssystem Just-in-Time-Compilation grundsätzlich. Dort ist Ahead-of-Time-Kompilierung keine Option unter mehreren, sondern die einzige Möglichkeit, überhaupt eine .NET-App zu betreiben.

Die Kostenrechnung: was der schnellere Start wirklich spart

Rechnen wir ein Beispiel durch, mit klar benannten Annahmen, keine universellen Zahlen.

Lambda, direkt gerechnet. Nimm eine Funktion mit 512 MB Speicher, 500.000 Aufrufen im Monat und einer für ein eher sporadisch genutztes Backend realistischen Kaltstart-Quote von 30 % (150.000 Kaltstarts). Braucht die Init-Phase bei einer klassischen, JIT-basierten Runtime im Schnitt 600 ms und bei einer Native-AOT-Binary nur 50 ms, sparst du pro Kaltstart 0,5 GB × 0,55 s = 0,275 GB-Sekunden. Bei rund 0,0000167 $ pro GB-Sekunde sind das über 150.000 Kaltstarts gerade einmal rund 0,69 $ im Monat. Direkt aus der Kaltstartdauer-Einsparung heraus ist das eher eine Randnotiz als ein Business Case.

Der eigentliche Hebel liegt woanders. Provisioned Concurrency berechnet reservierte Kapazität rund um die Uhr, unabhängig davon, ob die Funktion aufgerufen wird oder nicht. Wenn Native AOT deine Kaltstarts konsistent genug macht, dass du Provisioned Concurrency gar nicht mehr brauchst, sparst du einen laufenden Fixkosten-Posten, nicht Rundungsfehler pro Aufruf. Und weil Lambda nach Speicher × Zeit abrechnet, wirkt sich ein kleinerer Speicherbedarf auf jeden Aufruf aus, nicht nur auf Kaltstarts. Kannst du die konfigurierte Speichergröße dank Native AOT von 512 MB auf 256 MB senken, halbiert sich dieser Faktor bei jeder einzelnen Ausführung, nicht nur bei den seltenen Kaltstarts.

Kubernetes, nach Speicherdichte gerechnet. Braucht ein klassisch gestarteter .NET-Web-API-Prozess im Betrieb rund 300 MB RSS und eine Native-AOT-Variante nur rund 120 MB, dann passen auf einen Knoten mit 4.096 MB allokierbarem Speicher etwa 13 Pods im einen Fall, aber 34 im anderen. Das ist kein abstrakter Performance-Gewinn, das ist die Differenz zwischen drei Nodes und einem Node für dieselbe Last.

Die Kehrseite gehört genauso in die Rechnung. Für einen Dauerbetrieb-Service mit wenigen Neustarts, konstanter Last und Autoscaling, das ohnehin selten greift, ist der Startzeit-Vorteil praktisch irrelevant. Dort zählt Durchsatz im Steady State, und da ist Native AOT bestenfalls neutral. Die Migration lohnt sich dort nur, wenn auch die Speicherdichte für sich genommen einen Unterschied macht.

Wo es schlicht nicht geht

Ein Teil der Einschränkungen ist kein Bug, sondern die logische Konsequenz daraus, dass beim Publish alles statisch bekannt sein muss:

  • Kein Assembly.LoadFile / Assembly.LoadFrom. Plugin-Architekturen, die zur Laufzeit unbekannte Assemblies nachladen, funktionieren nicht, weil Trimming alles entfernt, was nicht statisch erreichbar ist.
  • Kein System.Reflection.Emit. Dynamische Codegenerierung zur Laufzeit ist ausgeschlossen. Das trifft ältere DI-Container, Mocking-Frameworks und ORMs, die zur Laufzeit dynamische Proxys erzeugen.
  • Kein C++/CLI, kein eingebautes COM (Windows). Klassische COM-Interop-Anwendungen bleiben auf dem JIT-Modell, sofern sie nicht auf quellcodegenerierte ComWrappers umgestellt werden.
  • EF Core Native AOT ist experimentell. Seit EF Core 9 gibt es einen MSBuild-Task für vorkompilierte Modelle und Queries, aber laut Microsoft ist das "noch nicht für den Produktionseinsatz geeignet". Ein datenbankschwerer Dienst mit EF Core ist heute kein guter Native-AOT-Kandidat.
  • Große Teile von ASP.NET Core fehlen. Die folgende Tabelle zeigt einen Ausschnitt der offiziellen Kompatibilitätsliste:
FeatureNative AOT
Minimal APIs✅ (teilweise)
gRPC
JWT-Authentifizierung
MVC (Controller mit Views)
Blazor Server
Session
Andere Authentifizierung (z. B. Cookie-basiert, externe OAuth-Provider)
SignalR✅ (teilweise)

Eine klassische serverseitig gerenderte Webanwendung mit Razor Views, Sessions und Cookie-basiertem Login ist damit aktuell schlicht kein Native-AOT-Kandidat, unabhängig davon, wie sehr der schnellere Start reizen würde.

  • NuGet-Ökosystem ist uneinheitlich. Viele populäre Bibliotheken nutzen Reflection zur Typerkennung, laden Abhängigkeiten bedingt zur Laufzeit oder generieren Code dynamisch. Seit .NET 10 gibt es das IsAotCompatible-Attribut, um das explizit zu markieren, aber längst nicht jede Bibliothek trägt es schon. Prüfe deine Abhängigkeiten, bevor du eine Migration einplanst, nicht danach.
  • Kein "einmal bauen, überall laufen" mehr. Jede Zielplattform braucht ihren eigenen Publish-Lauf (win-x64, linux-arm64, osx-arm64, …), und eine unter Ubuntu 20.04 gebaute Linux-Binary läuft zwar auf 20.04 und neuer, aber nicht auf älteren Versionen. Das erhöht die Komplexität deiner CI-Matrix gegenüber einem einzigen framework-abhängigen Build spürbar.

Setup: von PublishAot zum lauffähigen Artefakt

Migriere nicht den Monolithen am Stück. Nimm eine einzelne Lambda-Funktion oder einen einzelnen Minimal-API- bzw. gRPC-Dienst und arbeite dich daran durch die vollständige Kette vom Projekt bis zum Docker-Image.

Projekt und Code

Aktiviere Native AOT im Projekt:

<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <StripSymbols>true</StripSymbols>
</PropertyGroup>

InvariantGlobalization ist optional, spart aber zusätzlich Größe und Startzeit, wenn du keine kulturspezifische Sortierung oder Formatierung brauchst, prüfe das an echten Daten, nicht nur an Zahlen.

Im Program.cs brauchst du CreateSlimBuilder statt CreateBuilder, und für jede über HTTP transportierte Klasse einen quellcodegenerierten JSON-Kontext:

using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

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

var app = builder.Build();

app.MapGet("/health", () => Results.Ok(new HealthStatus("ok")));

await app.RunAsync();

record HealthStatus(string Status);

[JsonSerializable(typeof(HealthStatus))]
partial class AppJsonContext : JsonSerializerContext;

Veröffentlicht wird für eine konkrete Plattform, nicht "any CPU":

dotnet publish -c Release -r linux-x64 --self-contained

Lies dabei jede Warnung, die der Befehl ausgibt (IL2xxx/IL3xxx-Codes). Eine Warnung, die du ignorierst, kann zur Laufzeit zu einer Exception werden, nicht zu einer Fehlfunktion mit Fallback.

Docker-Image

Für ein Kamal-artiges Deployment brauchst du einen Multi-Stage-Build: Die Build-Stage braucht den vollen SDK-Container samt Compiler-Toolchain (clang, zlib1g-dev), die finale Stage nur noch die ausführbare Datei:

FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN apt-get update && apt-get install -y clang zlib1g-dev \
    && dotnet publish -c Release -r linux-x64 --self-contained -o /app

FROM mcr.microsoft.com/dotnet/runtime-deps:10.0
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
ENTRYPOINT ["./MyService"]

Das finale Image enthält keine .NET-Runtime mehr, nur noch die native Binary und ihre Betriebssystem-Abhängigkeiten, entsprechend kleiner als ein klassisches self-contained Deployment.

Sonderfall: AWS Lambda Custom Runtime

Für Lambda brauchst du zusätzlich Amazon.Lambda.RuntimeSupport und Amazon.Lambda.Core:

using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;

await LambdaBootstrapBuilder.Create(Handler, new SourceGeneratorLambdaJsonSerializer<AppJsonContext>())
    .Build()
    .RunAsync();

static OrderResult Handler(OrderRequest request) => new(request.OrderId, "processed");

Wichtig: AWS erwartet bei einer Custom Runtime, dass die native Binary im Deployment-Paket exakt bootstrap heißt. Veröffentlichst du normal, heißt die Datei wie dein Projekt, nicht bootstrap, und Lambda findet nichts zum Ausführen. Benenne sie im Publish-Schritt um, oder setze <AssemblyName>bootstrap</AssemblyName> im Projekt.

Stolperfallen in der Praxis

  • dotnet build zeigt fast nie Warnungen, dotnet publish schon. Die vollständige Trimming- und AOT-Analyse läuft erst beim Publish. Läuft deine CI-Pipeline für Pull Requests nur dotnet build, merkst du von einer kaputten Abhängigkeit erst beim tatsächlichen Deploy etwas, im schlechtesten Fall in Produktion. Nimm dotnet publish in die PR-Pipeline auf, nicht erst in den Deploy-Job.
  • Vergessener JsonSerializerContext fällt erst zur Laufzeit auf. Ohne quellcodegenerierten Kontext greift System.Text.Json auf Reflection zurück, die unter Native AOT nicht funktioniert. Der Fehler zeigt sich nicht beim Publish, sondern als Exception beim ersten tatsächlichen Serialisierungsversuch, typischerweise beim ersten echten Request.
  • Reflection-basierte DI-Auto-Registrierung liefert leere Ergebnisse statt eines Fehlers. Bibliotheken wie Scrutor scannen Assemblies zur Laufzeit nach Typen, die zum Registrieren passen. Unter Native AOT ist das, was gescannt werden könnte, längst weggetrimmt, das Ergebnis ist ein Service-Container, der bei der Auflösung leise nichts findet, statt beim Start laut zu scheitern. Ersetze Auto-Registrierung durch explizite AddSingleton<TInterface, TImplementation>()-Aufrufe mit konkreten Typen.
  • Ein auf Ubuntu gebautes Binary läuft nicht auf Alpine. Alpine nutzt musl statt glibc als C-Bibliothek. Baust du mit -r linux-musl-x64 auf einem glibc-basierten SDK-Image, verlinkt das Ergebnis trotzdem den glibc-Interpreter, und der Container startet nicht. Baue für Alpine-Ziele mit einem Alpine-basierten SDK-Image (mcr.microsoft.com/dotnet/sdk:10.0-alpine), nicht mit -r linux-musl-x64 auf einem Debian-Image.
  • Die Custom-Runtime-Binary heißt nicht bootstrap. Siehe oben, ein leicht zu übersehender, aber vollständig blockierender Deployment-Fehler bei AWS Lambda.

Miss anschließend in deiner eigenen Umgebung, nicht anhand von Community-Benchmarks. Ein Kaltstart in deiner Lambda-Funktion mit deinen tatsächlichen Abhängigkeiten kann sich ganz anders verhalten als das Hello-World-Beispiel aus der Dokumentation.

Fazit

Native AOT löst ein sehr konkretes Problem: Startzeit und Speicherbedarf, die in Serverless- und hochskalierenden Cloud-Umgebungen direkt zu Geld werden, seit August 2025 bei AWS Lambda sogar wörtlich. Der direkte Effekt aus der reinen Kaltstartdauer ist oft kleiner, als man erwartet, der eigentliche Hebel liegt in dem, was du dank Native AOT nicht mehr bezahlen musst: Provisioned Concurrency, überdimensionierten Speicher, ungenutzte Puffer-Replicas.

Gleichzeitig ist die Liste dessen, was nicht geht, lang genug, um sie ernst zu nehmen: kein Reflection.Emit, kein dynamisches Laden von Assemblies, kein MVC mit Views, EF Core noch experimentell, ein NuGet-Ökosystem, das erst nachzieht. Für einen schlanken, JSON-basierten Service in einer Serverless- oder hochskalierenden Umgebung ist Native AOT heute eine reife Option. Für eine klassische, EF-Core-gestützte Webanwendung mit Sessions und Views ist es das noch nicht, und vermutlich noch für eine Weile nicht.

Quellen

Brauchst du Unterstützung?

Überlegt ihr, ob sich Native AOT für eure .NET-Workloads lohnt, oder wollt ihr eure Cloud-Rechnung grundsätzlich auf den Prüfstand stellen? Wir analysieren mit dir, wo Kaltstarts, Speicherbedarf und Instanzdichte tatsächlich Geld kosten, und wo sich eine Migration lohnt und wo eben nicht. Melde dich einfach bei uns.

Kontakt aufnehmen