.NET 11 Native AOT vs .NET 10: Size, Startup, What Breaks
Last updated: November 13, 2026 · Tested with .NET 11.0.0 (SDK 11.0.100) and .NET 10.0.12 (SDK 10.0.401), ILCompiler 11.0.0 / 10.0.12, Visual Studio 2022 C++ tools, Windows 11 x64
Short answer: Native AOT in .NET 11 is about the same as in .NET 10 for size and startup. On my laptop, the webapiaot minimal API published to a 12.0 MB exe (11.6 MB on .NET 10). It answered its first request in a median 203 ms (189 ms on .NET 10), about twice as fast as the JIT build, with 22 MB of working set instead of 48–50 MB. The real .NET 11 changes are at the edges. [UnmanagedCallersOnly] methods that return bool now crash under AOT. The faster AOT publish that Microsoft documents is still opt-in in SDK 11.0.100. And the dotnet CLI now starts through its own native AOT path.
The usual things still break the same way in both versions: reflection-based System.Text.Json, MakeGenericType over value types, MVC controllers and EF Core model building. Below are the exact warnings and errors I got, the fixes, and the numbers. Every app was published with PublishAot on both SDKs, from the same source.
| Same app, Windows x64 | .NET 10 Native AOT | .NET 11 Native AOT | .NET 11 JIT (framework-dependent) |
|---|---|---|---|
| Minimal API exe size | 11.63 MB | 12.04 MB | 0.94 MB + shared runtime |
| Minimal API, start → first response (median) | 189 ms | 203 ms | 384 ms |
| Minimal API working set after first request | 22.1–22.3 MB | 22.3–22.5 MB | 49.3–49.6 MB |
| Console app exe size | 2.11 MB | 2.18 MB | 0.09 MB + shared runtime |
| Console app, start → exit (median) | 53.9 ms | 56.1 ms | 84.6 ms |
| Console app working set | 9.3 MB | 9.3 MB | 23.0–23.1 MB |
[UnmanagedCallersOnly] returning bool | works | InvalidProgramException, process dies | InvalidProgramException (also on .NET 10) |
AOT publish skips the full Build | no | only with UseOptimizedPublish=true | n/a |
dotnet --version (median) | 187 ms | 120 ms | 398 ms with DOTNET_CLI_ENABLEAOT=0 |
How I Tested It
The folder has five small projects: Hello (console), Api (the dotnet new webapiaot template, which SDK 10 and SDK 11 generate identically apart from the package version), Breaks (things that fail under AOT), Mvc and Ef, plus Uco for the interop change. One Directory.Build.props switches the target framework and the publish mode, so the same source gets published three ways on each SDK: Native AOT, ReadyToRun (framework-dependent) and plain JIT (framework-dependent):
<PropertyGroup Condition="'$(Mode)' == 'aot'">
<PublishAot>true</PublishAot>
</PropertyGroup>
<PropertyGroup Condition="'$(Mode)' != 'aot'">
<SelfContained>false</SelfContained>
</PropertyGroup>
<PropertyGroup Condition="'$(Mode)' == 'r2r'">
<PublishReadyToRun>true</PublishReadyToRun>
</PropertyGroup>
Every project also sets InvariantGlobalization (the template does) and RuntimeIdentifier win-x64. The console app serializes one record with a source-generated JsonSerializerContext and prints its own working set:
var todo = new Todo(1, "Walk the dog", new DateOnly(2026, 11, 13));
Console.WriteLine(JsonSerializer.Serialize(todo, AppJsonContext.Default.Todo));
Console.WriteLine($"Runtime {Environment.Version}, dynamic code compiled: {RuntimeFeature.IsDynamicCodeCompiled}");
Console.WriteLine($"Working set: {Environment.WorkingSet / 1048576.0:F1} MB");
{"Id":1,"Title":"Walk the dog","DueBy":"2026-11-13","IsComplete":false}
Runtime 11.0.0, dynamic code compiled: False
Working set: 9.3 MB
A Python script starts each published exe, times it from process start until the exit (console) or until the response to the first GET /todos comes back (API), and then reads the working set. It runs 20 rounds plus a warm-up and interleaves all six variants in each round. That way, when this Core Ultra 5 125U laptop throttles, all of them are hit about equally. I report min, median and max, because a single number on a laptop doesn’t mean much.
One setup detail: the first AOT publish failed with 'vswhere.exe' is not recognized as an internal or external command. The ILCompiler looks for link.exe through vswhere.exe, and from Git Bash that folder (C:\Program Files (x86)\Microsoft Visual Studio\Installer) wasn’t on PATH. Adding it fixed it. A Developer Command Prompt already has it.
Size: .NET 11 Native AOT Binaries Are 3% Larger
Publish output size (MB, .pdb excluded)
app variant exe folder files
Hello net10.0 aot 2.11 2.11 1
Hello net11.0 aot 2.18 2.18 1
Api net10.0 aot 11.63 11.63 2
Api net11.0 aot 12.04 12.04 2
Api net10.0 jit 0.15 0.92 8
Api net11.0 jit 0.07 0.94 8
The console app grew by 70 KB and the minimal API by about 400 KB. That’s not enough to matter for most deployments, but don’t expect .NET 11 to shrink your AOT binaries. The second file in the API’s folder is Api.staticwebassets.endpoints.json, a small manifest the Web SDK writes. The JIT rows look tiny because they’re framework-dependent and need the shared runtime installed. On the .NET 11 side, the apphost Api.exe itself went from 0.15 MB to 0.07 MB.
The .pdb files aren’t in those numbers, and they’re big: Hello.pdb was 10.2 MB on .NET 10 and 10.3 MB on .NET 11, nearly five times the size of the exe. Don’t ship them by accident.
Startup and Memory: AOT Still Halves It, .NET 11 Doesn’t Add Much
Console app (Hello): process start -> exit
variant min median max working set min-max
net10.0 aot 51.4 53.9 63.1 9.3-9.3
net10.0 r2r 84.9 93.6 105.0 23.4-23.4
net10.0 jit 88.5 95.0 115.6 23.4-23.5
net11.0 aot 51.6 56.1 60.2 9.3-9.3
net11.0 r2r 76.3 81.8 97.4 22.9-23.1
net11.0 jit 78.9 84.6 115.9 23.0-23.1
Minimal API (webapiaot): process start -> first GET /todos response
variant min median max working set min-max
net10.0 aot 129.9 188.7 278.1 22.1-22.3
net10.0 r2r 274.5 392.2 655.2 47.8-48.2
net10.0 jit 294.7 398.8 690.0 47.7-48.2
net11.0 aot 165.1 203.3 310.9 22.3-22.5
net11.0 r2r 284.5 401.9 559.4 49.3-49.6
net11.0 jit 292.2 383.5 576.8 49.3-49.6
What I take from this:
- AOT vs JIT is the big difference, on both versions. The minimal API answers its first request in roughly half the time and uses less than half the memory. The console app starts in about 55 ms instead of 85–95 ms.
- .NET 11 AOT vs .NET 10 AOT is noise. The API’s median was 14 ms slower on .NET 11, but the ranges overlap almost completely (130–278 vs 165–311 ms). On a throttling laptop, I wouldn’t call that a regression without a proper lab run.
- The JIT console app got a bit faster on .NET 11 (median 84.6 vs 95.0 ms), but JIT and ReadyToRun API startup didn’t move.
- ReadyToRun barely helped these small apps. Most of the time goes to starting the runtime, not to JIT-compiling my code.
I didn’t measure throughput (requests per second). For a long-running service, startup matters less than steady-state speed, and JIT with tiered PGO can be faster there. If your app runs for days, measure that before you switch.
Publish Time and the “Full Build Skipped” Change Is Opt-In
Microsoft’s .NET 11 breaking-change list includes Full build skipped for Native AOT publish. According to that page, the SDK no longer runs the full Build target when PublishAot=true, so AfterBuild, PostBuildEvent and AfterTargets="Build" targets stop running. I added both kinds of hook to the console project to see it happen:
<PreBuildEvent>echo PreBuildEvent ran</PreBuildEvent>
<Target Name="StampAfterBuild" AfterTargets="Build">
<Message Importance="high" Text="AfterTargets=Build ran" />
</Target>
On SDK 11.0.100 GA, nothing had changed. The AfterTargets="Build" target still ran, and bin\ still got the whole self-contained managed deployment (201 files) that the AOT compiler never uses. The default is still off:
<!-- For AOT publish, we only need Compile (not full Build) since IlcCompile reads from
@(IntermediateAssembly) in obj\, not from bin\ output. Skipping Build avoids producing
unnecessary self-contained managed output (apphost, deps.json, runtimeconfig, runtime
pack files) in the output directory that would confuse users and automation.
This optimization is opt-in in .NET 11. Set UseOptimizedPublish=true to enable it. -->
<UseOptimizedPublish Condition="'$(UseOptimizedPublish)' == '' and '$(PublishAot)' == 'true'">false</UseOptimizedPublish>
That’s from Microsoft.NET.Publish.targets in SDK 11.0.100. dotnet msbuild -getProperty:UseOptimizedPublish on the AOT project returns false. With -p:UseOptimizedPublish=true, the behavior described in the docs shows up:
### SDK 11.0.100, UseOptimizedPublish=default, run 1
publish: 8.7 s
PreBuildEvent ran
AfterTargets=Build ran
files in bin/: 201
files in publish/: Hello.exe Hello.pdb
### SDK 11.0.100, UseOptimizedPublish=true, run 1
publish: 7.2 s
PreBuildEvent ran
files in bin/: 2
files in publish/: Hello.exe Hello.pdb
Two things the docs page doesn’t match. First, PreBuildEvent still runs in the optimized path. It’s in the SDK’s list of targets for that path, and my echo printed. Second, I couldn’t see a speedup. Over three runs each, the default publish took 7.2–8.7 s and the optimized one 7.2–8.4 s, because native code generation dominates. The real gain is a clean bin\. On SDK 10, UseOptimizedPublish=true is ignored, and the AfterTargets="Build" target ran as usual.
So if you have AfterBuild targets that sign, copy or zip something, nothing changes for you today. Move them to AfterTargets="Publish" anyway (or BeforeTargets="CoreCompile" for generated sources, as the docs say), because this will probably become the default.
Clean publish times on this laptop (single runs, so treat them as rough): console app 12.3 s (.NET 10) and 8.3 s (.NET 11), minimal API 29.5 s and 33.0 s. ReadyToRun took 4–6 s and plain JIT 1–3 s. AOT publish is slow enough that you won’t want it in your inner loop. Keep dotnet run (JIT) for development and publish AOT in CI.
What Still Breaks: Reflection-Based System.Text.Json
This is the one most people hit first, and it’s identical on .NET 10 and .NET 11. The Breaks project calls the reflection overload:
var person = new Person("Ann", 42);
Try("1. STJ, reflection", () => JsonSerializer.Serialize(person));
Try("2. STJ, source-generated", () => JsonSerializer.Serialize(person, AppJsonContext.Default.Person));
Publishing shows both analyzer warnings. IL2026 is about trimming and IL3050 about AOT:
Breaks\Program.cs(8,33): warning IL2026: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. JSON serialization and deserialization might require types that cannot be statically analyzed. Use the overload that takes a JsonTypeInfo or JsonSerializerContext, or make sure all of the required types are preserved.
Breaks\Program.cs(8,33): warning IL3050: Using member 'System.Text.Json.JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. JSON serialization and deserialization might require types that cannot be statically analyzed and might need runtime code generation. Use System.Text.Json source generation for native AOT applications.
They’re only warnings, so the publish succeeds. The exe then fails at runtime, while the JIT build of the same code works:
.NET 11:
Dynamic code supported: False
1. STJ, reflection: InvalidOperationException: Reflection-based serialization has been disabled for this application. Either use the source generator APIs or explicitly configure the 'JsonSerializerOptions.TypeInfoResolver' property.
2. STJ, source-generated: {"Name":"Ann","Age":42}
The fix is a source-generated context, passed to every Serialize/Deserialize call. In ASP.NET Core, you insert it into TypeInfoResolverChain instead, the way the webapiaot template does:
[JsonSerializable(typeof(Person))]
internal partial class AppJsonContext : JsonSerializerContext { }
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonSerializerContext.Default);
});
Every type you return or accept has to be listed, including arrays and lists ([JsonSerializable(typeof(Todo[]))] in the template). I compared the two serializers in more detail in System.Text.Json vs Newtonsoft on .NET 10. If you write small tools as file-based apps, note that they have PublishAot=true by default, so you hit this even with dotnet run.
What Still Breaks: MakeGenericType, and What Surprisingly Didn’t
The rest of Breaks tries the other classic dynamic-code patterns:
Try("3. Newtonsoft", () => Newtonsoft.Json.JsonConvert.SerializeObject(person));
Try("4. MakeGenericType", () =>
{
Type itemType = Type.GetType(args.Length > 0 ? args[0] : "System.Guid")!;
object list = Activator.CreateInstance(typeof(List<>).MakeGenericType(itemType))!;
return list.GetType().ToString();
});
Try("5. Expression.Compile", () =>
{
Expression<Func<int, int>> square = x => x * x;
return square.Compile()(7).ToString();
});
All of them produced warnings (IL2026/IL3050 for Newtonsoft.Json, IL2057 for Type.GetType(string), IL3050 for MakeGenericType, and IL3053 for System.Linq.Expressions). Then, on both .NET 10 and .NET 11, they all ran:
3. Newtonsoft: {"Name":"Ann","Age":42}
4. MakeGenericType: System.Collections.Generic.List`1[System.Guid]
5. Expression.Compile: 49
6. GetType(string) + GetProperties: Name,Age
Don’t read that as “the warnings are false alarms”. List<Guid> works only because the framework already uses that instantiation somewhere, so its native code exists. When I passed a value type that nothing in the app had used with List<T>, it failed. Same result on both versions:
4. MakeGenericType: NotSupportedException: 'System.Collections.Generic.List`1[System.Int128]' is missing native code or metadata. This can happen for code that is not compatible with trimming or AOT. Inspect and fix trimming and AOT related warnings that were generated when the app was published. For more information see https://aka.ms/nativeaot-compatibility
Newtonsoft.Json worked for this record because the compiler kept its properties. Anything it can’t see statically (polymorphic types, types created by name, TypeNameHandling) is a gamble. Expression.Compile() works because System.Linq.Expressions falls back to its expression interpreter when dynamic code isn’t supported. Interpreted delegates are slower than compiled ones, and I didn’t measure by how much here. The rule I follow: zero AOT warnings before shipping, because a warning means “works until it doesn’t”.
What Still Breaks: MVC Controllers Return 404
This is the nastiest one, because nothing crashes. A minimal MVC app, AddControllers() plus one [ApiController]:
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.MapControllers();
[HttpGet("{id}")]
public ActionResult<Todo> Get(int id) => new Todo(id, "Walk the dog");
The publish gave about 90 warning lines on both SDKs, starting with this one:
Mvc\Program.cs(4,1): warning IL2026: Using member 'Microsoft.Extensions.DependencyInjection.MvcServiceCollectionExtensions.AddControllers(IServiceCollection)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. MVC does not currently support trimming or native AOT. https://aka.ms/aspnet/trimming
The exe starts normally and listens. Every controller route then returns 404, because MVC discovers controllers by reflection and the trimmer removed them:
info: Microsoft.AspNetCore.Mvc.Infrastructure.DefaultActionDescriptorCollectionProvider[1]
No action descriptors found. This may indicate an incorrectly configured application or missing application parts. To learn more, visit https://aka.ms/aspnet/mvc/app-parts
Request finished HTTP/1.1 GET http://127.0.0.1:5390/todos/1 - 404 0 - 0.6761ms
It was the same on .NET 10 and .NET 11, and it matches the ASP.NET Core Native AOT compatibility table, which still lists MVC as not supported. Health checks that only hit a minimal endpoint would pass while every real route returns 404. If you want AOT, move the controllers to minimal APIs like the Api project.
What Still Breaks: EF Core Without a Compiled Model
An EF Core SQLite app (EF Core 10.0.13 / 11.0.0) published with 18 warning lines on SDK 11, IL2026/IL3050 on the DbContext constructor and on EnsureCreated(). It failed on the first use of the context:
InvalidOperationException: Model building is not supported when publishing with NativeAOT. Use a compiled model.
EF Core’s Native AOT support depends on a compiled model and precompiled queries, generated with dotnet ef dbcontext optimize. I didn’t install the dotnet-ef tool for this post, so I can’t tell you how far it gets on EF Core 11. The EF Core 11 new features post covers what changed in EF Core 11 itself. For an AOT service today, I’d look at Dapper.AOT or plain ADO.NET.
.NET 11 Breaking Change: UnmanagedCallersOnly With bool Now Crashes
This is the one AOT behavior that changed between the versions in my tests. An [UnmanagedCallersOnly] method is a C# function that native code calls through a function pointer, usually a callback you pass to a C library. Mine returns bool, and bool isn’t blittable while runtime marshalling is on:
delegate* unmanaged<int, bool> isEven = &Callbacks.IsEven;
Console.WriteLine($"IsEven(4) through the unmanaged pointer: {isEven(4)}");
[UnmanagedCallersOnly]
public static bool IsEven(int value) => value % 2 == 0;
### net10.0 jit (bool return, runtime marshalling on)
Unhandled exception. System.InvalidProgramException: Non-blittable parameter types are invalid for UnmanagedCallersOnly methods.
### net10.0 aot (bool return, runtime marshalling on)
IsEven(4) through the unmanaged pointer: True
### net11.0 aot (bool return, runtime marshalling on)
Unhandled exception. System.InvalidProgramException: Non-blittable parameter types are invalid for UnmanagedCallersOnly methods.
at Internal.Runtime.TypeLoaderExceptionHelper.CreateInvalidProgramException(ExceptionStringID, String)
at Callbacks.IsEven(Int32)
CoreCLR (the JIT build) always rejected this signature. .NET 10 Native AOT let it through, so code could pass every test as an AOT exe and fail under dotnet run. .NET 11 AOT now rejects it too (breaking-change page). The exception is thrown inside the callback, so with a real native caller you can’t catch it, and the process dies. Neither SDK printed a build warning for it.
The fix from the docs worked: add DisableRuntimeMarshalling to the assembly that declares the callback (or change bool to byte/int and convert by hand). With it, all four builds printed True:
[assembly: System.Runtime.CompilerServices.DisableRuntimeMarshalling]
### net11.0 aot-fixed
IsEven(4) through the unmanaged pointer: True
The attribute applies to the whole assembly, so [DllImport] signatures with string or bool parameters in that assembly lose their marshalling too. If you have both, put the callbacks in a separate assembly or switch them to byte.
The dotnet CLI Itself Now Runs Through Native AOT
Another .NET 11 entry, NativeAOT CLI command handling enabled by default, isn’t about your app. It’s about the dotnet command. Common commands go through a native fast path first, and DOTNET_CLI_ENABLEAOT=0 turns it off. I timed dotnet --version 20 times each:
dotnet --version
variant min median max
SDK 10.0.401 152.1 187.3 304.8
SDK 11.0.100 100.4 120.1 210.1
SDK 11.0.100 ENABLEAOT=0 349.0 398.3 553.0
That’s about 70 ms saved per call compared with SDK 10. The opt-out was more than three times slower than the default, and even twice as slow as SDK 10, so only use it if you find a real difference in CLI behavior. For a build script that calls dotnet dozens of times, the default saves a few seconds.
Not Tested: the lib Prefix on Unix
.NET 11 also adds the lib prefix to Native AOT library outputs on Unix. A NativeLib=Shared project named mylib now produces libmylib.so instead of mylib.so, and UseNativeLibPrefix=false turns that off. I have no Linux or macOS machine here, so I couldn’t check it. On Windows the output stays mylib.dll. If a deployment script or a dlopen call uses the old name, that’s where it breaks.
Should You Use Native AOT on .NET 11?
Use it for the same things as on .NET 10: CLI tools, functions and containers that start often, small minimal APIs, and sidecars where 22 MB instead of 50 MB matters. Upgrading from .NET 10 AOT to .NET 11 AOT won’t give you smaller or faster binaries. Upgrade for the runtime and library changes (my .NET 11 upgrade post lists the ones that fail at build time or startup). Then check these AOT-specific points:
- Search for
[UnmanagedCallersOnly]and check every parameter and return type.boolandcharnow kill the process under AOT. - Publish with
-p:UseOptimizedPublish=trueonce and see if any of yourAfterBuildtargets stop running. Fix them now, before this becomes the default. - Treat every IL2026, IL3050 and IL2057 warning as a bug. In my tests, the warned code either failed at runtime or worked only by luck.
- Run your integration tests against the published exe, not
dotnet run. The MVC 404s and the EF Core exception only appear there.
What I Didn’t Test
- Linux, macOS, Arm64 and containers. Everything here ran on Windows x64. The
libprefix change only applies to Unix. - Throughput and latency under load. I measured cold start and memory only, not requests per second.
- Trimmed self-contained JIT (
PublishTrimmed), which sits between the two on size. - EF Core compiled models and precompiled queries, gRPC, SignalR, and Dapper’s AOT generator.
- Optimization switches such as
OptimizationPreference=SizeorStackTraceSupport=false, which can shave more off the binary. - NuGet flagged
Microsoft.DotNet.ILCompiler10.0.12 (pulled in by SDK 10.0.401) with NU1903, a high-severity advisory. I didn’t look into the advisory. If you see that warning, update to the latest .NET 10 SDK patch.
Related: Native AOT deployment overview (Learn) · All .NET 11 breaking changes (Learn) · Upgrading to .NET 11: breaking changes and fixes · C# tutorials hub