.NET 9 Performance Improvements Every Developer Should Know
.NET 9 delivers meaningful performance gains — teams have seen up to 22% reduction in P99 latency with zero code changes, just a target framework swap. Let's walk through the improvements that matter most, with practical examples you can apply today.
Dynamic PGO: Enabled by Default
Dynamic Profile-Guided Optimization now runs by default in .NET 9. The JIT collects runtime profiling data and devirtualizes interface calls based on observed types — meaning your DI-heavy services get optimized automatically:
public class OrderProcessor
{
private readonly IOrderValidator _validator;
private readonly IInventoryService _inventory;
public OrderProcessor(IOrderValidator validator, IInventoryService inventory)
{
_validator = validator;
_inventory = inventory;
}
public async Task<OrderResult> ProcessAsync(Order order)
{
// PGO observes _validator is always ConcreteOrderValidator
// at runtime and devirtualizes this call automatically
var validation = await _validator.ValidateAsync(order);
if (!validation.IsValid)
return OrderResult.Failed(validation.Errors);
await _inventory.ReserveAsync(order.Items);
return OrderResult.Success(order.Id);
}
}
In benchmarks, Dynamic PGO reduces method dispatch overhead by 15-30% on services with heavy interface usage. No code changes needed — just retarget to net9.0 in your project file and the JIT handles everything. For applications with hundreds of DI-registered services, the cumulative improvement can be substantial.
The JIT profiles your application during the first few minutes of execution, identifies hot methods, and recompiles them with optimizations specific to your actual runtime behavior. This is especially powerful for web APIs where the same code paths execute millions of times.
To verify PGO is working in your application, enable the event counters and check the tiered compilation stats:
// In your diagnostic startup, confirm PGO is active
// Set these environment variables for detailed JIT diagnostics:
// DOTNET_TieredPGO=1 (default in .NET 9)
// DOTNET_TC_QuickJitForLoops=1
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddEventSourceLogger();
var app = builder.Build();
app.MapGet("/diagnostics/pgo", () =>
{
return Results.Ok(new
{
TieredCompilation = AppContext.TryGetSwitch(
"System.Runtime.TieredCompilation", out var enabled) ? enabled : true,
Runtime = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription
});
});
Native AOT: Production-Ready for Web APIs
.NET 9 closes the gap for Native AOT with improved System.Text.Json source generators, better trimming, and reduced binary sizes. Here are the startup benchmarks:
| Metric | .NET 9 JIT | .NET 9 AOT | Improvement |
|-----------------------|-------------|-------------|-------------|
| Cold start | 287ms | 38ms | 86% faster |
| Memory (startup) | 48 MB | 14 MB | 71% less |
| Binary size | N/A | 18 MB | - |
| First request latency | 12ms | 3ms | 75% faster |
For containerized microservices where cold start matters — think Kubernetes pod scaling — Native AOT is now a strong default choice. Setting up a project for AOT is straightforward:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
Keep in mind that AOT requires source generators for JSON serialization — reflection-based serialization won't work. You'll need to define a JsonSerializerContext for your types:
[JsonSerializable(typeof(OrderResult))]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<OrderItem>))]
public partial class AppJsonContext : JsonSerializerContext { }
// Register in Program.cs
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Add(AppJsonContext.Default));
Frozen Collections and New LINQ Methods
FrozenDictionary<TKey, TValue> received major optimizations in .NET 9. These immutable collections analyze your keys at construction time and select the optimal hashing strategy, outperforming Dictionary by 30-60% for read-heavy lookups:
public class FeatureFlagService
{
private readonly FrozenDictionary<string, bool> _flags;
public FeatureFlagService(IConfiguration config)
{
_flags = config.GetSection("FeatureFlags").GetChildren()
.ToFrozenDictionary(
x => x.Key,
x => bool.Parse(x.Value ?? "false"),
StringComparer.OrdinalIgnoreCase);
}
public bool IsEnabled(string featureName)
=> _flags.TryGetValue(featureName, out var enabled) && enabled;
}
New LINQ methods like CountBy and AggregateBy eliminate GroupBy allocations in hot paths. Here's a before-and-after comparison:
// Before (.NET 8) — allocates intermediate groupings
var categoryCounts = products
.GroupBy(p => p.Category)
.ToDictionary(g => g.Key, g => g.Count());
// After (.NET 9) — single-pass, minimal allocations
var categoryCounts = products
.CountBy(p => p.Category)
.ToDictionary();
// AggregateBy for running totals without GroupBy overhead
var revenueByCategory = orders
.AggregateBy(
o => o.Category,
seed: 0m,
(total, order) => total + order.Amount)
.ToDictionary();
These new methods cut memory allocations by up to 40% in data processing pipelines — particularly impactful in high-throughput API endpoints that aggregate data on every request. For applications processing thousands of requests per second, replacing GroupBy with CountBy in hot paths can noticeably reduce GC pressure and improve P99 latency.
SearchValues for High-Performance String Scanning
SearchValues<T> is another gem in .NET 9. It pre-computes optimal search strategies for character sets, making repeated string scanning significantly faster:
// Pre-compute once at startup
private static readonly SearchValues<char> s_invalidChars =
SearchValues.Create("<>&\"'");
public static bool ContainsInvalidChars(ReadOnlySpan<char> input)
=> input.ContainsAny(s_invalidChars);
This is ideal for input validation, log parsing, and protocol handling where you're scanning against the same character set repeatedly.
Key Takeaways
- Upgrade and measure first. Dynamic PGO alone gives most applications a measurable boost with zero code changes.
- Adopt FrozenDictionary for read-heavy lookups populated at startup.
- Evaluate Native AOT for new containerized microservices — the startup and memory characteristics are compelling.
- Replace GroupBy with CountBy/AggregateBy in hot paths for significant allocation reduction.
- Use SearchValues for repeated string scanning — input validation, log parsing, and protocol handling.
References
- What's new in .NET 9 — Official overview of all .NET 9 features and improvements
- Performance improvements in .NET 9 — Stephen Toub's detailed performance analysis with benchmarks
- Native AOT deployment — Microsoft documentation on publishing .NET apps with Native AOT
- FrozenDictionary<TKey,TValue> Class — API reference for frozen collections
- SearchValues<T> Class — API reference for high-performance value searching
Share this post
Comments
Ajit Gangurde
Software Engineer II at Microsoft | 15+ years in .NET & Azure