Native AOT in .NET: Smaller Containers Without the Magic

Native AOT in .NET: Smaller Containers Without the Magic
Native AOT is one of the most useful deployment options in modern .NET.
It can produce a self-contained native executable with fast startup, low memory usage, and no JIT compiler in the production container.
But it is not a switch that makes every application faster.
The right question is not:
"Can I enable Native AOT?"
It is:
"Does this service benefit enough to justify its compatibility constraints?"
JIT and Native AOT in plain English
Traditional .NET applications use the JIT compiler.
The application ships intermediate language, and the runtime compiles methods to machine code while the process starts and runs.
That gives .NET excellent flexibility. Reflection, dynamic loading, runtime code generation, and many libraries work naturally.
Native AOT compiles the application to native machine code during the build:

The production image no longer needs the full .NET runtime or SDK.
The trade-off is important: code that depends on runtime discovery must be made visible to the compiler or replaced with a source-generated or static alternative.
Where Native AOT fits well
Native AOT is a strong candidate for services that:
- start frequently, such as jobs, serverless functions, and short-lived workers;
- run many replicas where memory adds up;
- process a predictable set of message or HTTP types;
- have a small dependency graph;
- benefit from self-contained native binaries.
A transaction worker that receives a message, processes it, and calls an external API is a reasonable example.
If the service runs continuously for days, startup time may not matter much. Measure before changing the deployment model.
The trade-offs
Native AOT can improve startup time and reduce the runtime footprint, but it also changes the development and deployment constraints:
| Benefit | Cost |
|---|---|
| Fast startup | Longer publish times |
| Lower memory pressure | More compatibility warnings |
| Self-contained native executable | One binary per target runtime |
| Small runtime-only container | Reflection and dynamic loading need attention |
| No JIT startup work | Native binaries can be larger than expected |
The exact memory reduction depends on the application. Do not promise a fixed number such as "150 MB to 15 MB" without measuring your own workload.
First step: publish an AOT build
Add the setting to the executable project:
<PropertyGroup> <TargetFramework>net10.0</TargetFramework> <PublishAot>true</PublishAot> </PropertyGroup>
Then publish for the target platform:
dotnet publish -c Release -r linux-x64 --self-contained true
Treat every trim and AOT warning as a compatibility issue to investigate.
Do not silence warnings just to make the build green. A suppressed warning can become a missing type, property, or handler at runtime.
Make JSON compile-time friendly
JSON serialization is a common place where reflection appears.
For known DTOs, use System.Text.Json source generation:
using System.Text.Json; using System.Text.Json.Serialization; public sealed record Transaction(string Id, decimal Amount); [JsonSerializable(typeof(Transaction))] internal partial class AppJsonContext : JsonSerializerContext; var transaction = new Transaction("tx-123", 42.50m); var json = JsonSerializer.Serialize( transaction, AppJsonContext.Default.Transaction);
The source generator creates the metadata at build time instead of discovering it through reflection at runtime.
The same principle applies to serializers, dependency injection registrations, ORM mappings, and message consumers: prefer explicit registrations and compile-time generation when the library supports it.
Replace reflection-heavy mapping where needed
Automatic mapping is convenient, but a Native AOT build must be able to discover the mapping code.
For a small number of DTOs, manual mapping is often the simplest option:
public sealed record TransactionResponse(string Id, decimal Amount); static TransactionResponse ToResponse(Transaction transaction) => new(transaction.Id, transaction.Amount);
If the mapping surface is large, choose a mapper that explicitly supports source generation and verify its AOT support before adopting it.
Containerize it with Podman
Native AOT works well with a multi-stage container build.
The SDK image compiles the application. The final image contains only the native executable and the minimal native dependencies:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish ./Worker/Worker.csproj \
-c Release \
-r linux-x64 \
--self-contained true \
-o /app/publish
FROM mcr.microsoft.com/dotnet/runtime-deps:10.0-noble-chiseled
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["./Worker"]
Build and run it with Podman:
podman build -t transaction-worker .
podman run --rm transaction-worker
The runtime-deps image is enough for a Native AOT executable. The chiseled variant removes packages that the application does not need, reducing the attack surface.
That does not make the application secure by itself. Keep the container non-root, scan the image, restrict permissions, and configure secrets outside the image.
Compatibility checklist
Before choosing Native AOT, check:
- Does every production dependency support trimming and Native AOT?
- Does the service use reflection, runtime type discovery, or dynamic assembly loading?
- Are all JSON and message types known at build time?
- Does the target platform match the publish runtime identifier?
- Can the team accept longer builds and platform-specific artifacts?
- Have startup time, memory, and throughput been measured before and after?
Messaging clients deserve special attention. A broker SDK may work in a normal JIT deployment and still have incomplete Native AOT support. Confirm support in the SDK documentation and run the real worker against the broker before production deployment.
Native AOT is not a universal optimization
Native AOT is usually a better fit for:
- small APIs with predictable endpoints;
- background workers;
- command-line tools;
- serverless functions;
- high-replica services where memory matters.
It may be a poor fit for:
- plugin systems that load assemblies dynamically;
- applications built around runtime-generated proxies;
- highly reflection-driven frameworks;
- services where startup and memory are already insignificant.
For a long-running application, a regular framework-dependent deployment may be simpler and just as effective.
The practical decision
Use Native AOT when all three are true:
- Startup or memory is a measured problem.
- The application has a predictable runtime shape.
- Its dependencies have verified AOT support.
Otherwise, keep the normal .NET deployment and spend the saved engineering time somewhere users can feel.
Further reading
- Native AOT deployment overview
- Native AOT compatibility
- System.Text.Json source generation
- .NET container images
- Podman documentation
Takeaway: Native AOT is a focused deployment tool, not a performance checkbox. Measure the workload, make runtime behavior explicit, and use a minimal runtime-dependencies image only when the resulting service is simpler and cheaper to operate.
