Announcing .NET 9

Microsoft shipped .NET 9 in November 2024, and unlike some releases where the changelog reads like a list of small fixes, this one has a few items that genuinely change how you would design a system. Over a thousand performance changes landed across the runtime, the JIT, and the base class libraries. .NET Aspire got its second major release. And the AI abstractions that Microsoft has been building with the Semantic Kernel team finally shipped as first-class packages instead of preview add-ons.

Before getting into features, one detail matters for planning: .NET 9 is a Standard Term Support (STS) release, not Long Term Support (LTS). That means 18 months of patches, not three years. If you are running .NET 8 (LTS) in production today, treat .NET 9 as a preview of where .NET 10 (the next LTS, due late 2025) is heading, rather than a release you commit critical systems to for years. Teams that can afford to re-platform every 18 months get earlier access to the runtime improvements described below; teams that cannot should wait.

Performance: the Server GC change is the one to pay attention to

The headline performance work is a change to how Server GC sizes itself. Previously, Server GC scaled its memory usage to the resources available on the machine or container, which was great for throughput but meant a container with 16 cores would happily hold onto far more memory than your app actually needed. In .NET 9, Server GC adapts to what the application is actually using, closing much of the gap that used to force teams to choose Workstation GC just to keep memory usage predictable in the cloud.

Microsoft’s own TechEmpower benchmark run shows roughly 15% higher requests per second and a 93% drop in memory usage on .NET 9 versus .NET 8 for a minimal API workload. Numbers like this are useful for spotting a trend, but they come from a synthetic JSON benchmark on specific hardware, so treat them as a starting point, not a guarantee. Run your own before-and-after load test on the actual workload you care about before you plan capacity around these figures.

TechEmpower JSON benchmark comparing .NET 8 and .NET 9 minimal APIs (source: Microsoft .NET Blog)
TechEmpower JSON benchmark comparing .NET 8 and .NET 9 minimal APIs (source: Microsoft .NET Blog)

There is a real trade-off buried in the GC change: Microsoft notes the memory improvement comes with a modest throughput cost, which may or may not be noticeable depending on your workload. If you have a latency-sensitive service where every bit of throughput matters, benchmark before switching, and remember the legacy Server GC behavior is still available through a runtime configuration knob if you need to fall back.

Other runtime wins worth knowing about: RyuJIT picked up better code generation for Arm64, loops, and bounds checks, and exceptions are around 50% faster because they now share the exception model that Native AOT uses. Dynamic PGO also got smarter about type casts, generating fast paths for both common and previously unseen casts like (IFoo)myFoo or myFoo is IFoo, giving up to 70% faster execution in some cases. The catch is that this particular optimization requires ReadyToRun to be disabled, so it will not help you automatically if your deployment pipeline publishes with R2R images.

LINQ and System.Text.Json both got targeted fixes rather than a general rewrite. Take and DefaultIfEmpty are up to 10x faster when the underlying collection is empty, and SequenceEqual’s array fast path now also applies to List<T>. On the JSON side, JsonProperty.WriteTo can write UTF8 bytes directly instead of allocating a string first, and JsonObject sizes its backing storage correctly when it can determine a count up front. None of these are dramatic on their own, but if your service does a lot of JSON serialization at scale, they add up.

.NET Aspire 9: the second release is where the rough edges start disappearing

.NET Aspire 9 arrived about six months after the first release, and the changes are mostly about making the local development loop less painful rather than adding entirely new capability. You can now start and stop individual resources directly from the Aspire dashboard, containers stay alive between debug sessions instead of restarting every time you hit F5, and a new WaitFor API lets you express resource startup ordering explicitly instead of hoping your database container is ready before your app tries to connect.

New integrations landed for OpenAI, Ollama, and Milvus, on top of the existing Azure and container integrations. Deployment to Azure Container Apps is smoother, and there is preview support for Azure Functions with Aspire, which matters if you are building event-driven systems and want the same orchestration experience you get with web apps and APIs.

If you tried Aspire 8 and found the tooling still felt early, Aspire 9 is worth a second look, particularly for the dashboard improvements. That said, some of the newer integrations are still marked preview, so pin versions carefully if you adopt them in anything beyond a side project, and expect breaking changes before general availability.

AI: Microsoft.Extensions.AI finally gives .NET a common abstraction layer

The most consequential AI change in this release is not a new model or a flashy demo, it is a set of abstractions. Microsoft.Extensions.AI and Microsoft.Extensions.VectorData, built in collaboration with the Semantic Kernel team, give you a common C# interface for talking to chat models, embeddings, and vector stores regardless of which provider is behind them. Before this, every SDK for OpenAI, Azure OpenAI, or a local Ollama model had its own shape, which made it painful to swap providers or write code that worked against more than one of them.

Early adopters like Pieces and OllamaSharp are already building against these abstractions, which is a reasonable signal of stability, but the packages are still young. If you adopt them now, expect some API surface to shift before things settle, the same way ASP.NET Core’s early abstractions moved around before 1.0. It is still a better bet than hand-rolling your own provider-agnostic wrapper, which is what most teams were doing before this shipped.

On the supporting side, Microsoft.ML.Tokenizers picked up better tokenizer support for GPT-family models, Llama, Phi, and Bert, along with Byte-Level BPE, SentencePiece, and WordPiece algorithms. There is also a new Tensor<T> type meant to simplify passing multidimensional data between AI libraries without everyone inventing their own array wrapper. Neither is exciting on its own, but both remove boilerplate that used to sit between .NET code and the Python-first AI ecosystem.

GitHub Copilot gets deeper into the .NET debugging workflow

Copilot’s .NET-specific features moved past code completion into debugging and testing, assuming you are on current Visual Studio or VS Code builds. AI-assisted variable inspection during debugging, an AI-powered LINQ expression editor inside the IEnumerable visualizer, a Fix with Copilot action for resolving compiler and analyzer errors, and Copilot-assisted test debugging are all part of this release. Better C# completions also pull additional context from related files in your solution instead of just the current file.

These are genuinely useful day to day, but they require the latest IDE and Copilot versions to work, so if your team is on an older Visual Studio install these features simply will not appear until you update.

ASP.NET Core and Blazor: static file handling and OpenAPI are the practical wins

ASP.NET Core in .NET 9 now fingerprints static web assets like JavaScript and CSS at build time, adding a content hash to the filename so browsers can cache them aggressively without serving stale versions after a deployment. Files are also precompressed with Brotli during publish, which shrinks download size without making your server do compression work on every request. Because these files are now served through endpoint routing rather than the older static files middleware, you can apply per-endpoint authorization to specific static assets, something that was awkward to do cleanly before.

Blazor’s most useful addition is the RendererInfo API, which lets a component check at runtime whether it is currently in the interactive render mode or still prerendering.

@if (RendererInfo.IsInteractive)
{
    <button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
}
else
{
    <p>One moment, please</p>
}

Before this API existed, a common mistake was rendering an interactive button during prerender, which either did nothing when clicked or, worse, silently failed because the circuit or WebAssembly runtime was not attached yet. Users would click a button and see no response, then assume the app was broken. With RendererInfo.IsInteractive, the component shows a plain message during prerender and swaps in the working button once the app is genuinely interactive, so there is no dead-click window. Blazor Server also got a friendlier reconnection UI that reconnects faster and reloads the page automatically if the connection drops for good, instead of leaving users staring at a frozen page.

For API developers, the Microsoft.AspNetCore.OpenAPI package now generates OpenAPI documents built in, pulling metadata from your code, attributes, and extension methods automatically. You can customize the output with transformers that operate on individual operations, schemas, or the whole document. It works with Minimal APIs in a Native AOT-friendly way, and you can generate the document at build time and wire it into a CI pipeline instead of only generating it at runtime. If you were using Swashbuckle purely for document generation, this removes a dependency for that specific job, though Swashbuckle’s UI and tooling ecosystem is still ahead in other respects.

Security changes are smaller but worth knowing: setting up a trusted HTTPS development certificate on Linux is easier, Blazor has a built-in way to flow authentication state to the client, OAuth and OIDC requests support Pushed Authorization Requests (PAR), and Kestrel exposes better connection metrics so you can actually see why a connection failed instead of guessing from generic logs.

.NET MAUI and Windows: reliability over new features

.NET MAUI’s stated priority for this release was quality and reliability rather than new capability, which is a fair call given how many teams have reported inconsistent behavior across platforms in earlier versions. CollectionView and CarouselView were rewritten for iOS and Mac Catalyst, Native AOT and trimming got better support for smaller app sizes, and a new project template ships with 14 free Syncfusion controls demonstrating recommended patterns for MVVM, database access, and navigation. Syncfusion’s community contributions jumped noticeably in the months before this release, which is reflected in how much of the new template content comes from their controls.

Windows developers get Native AOT support for WinUI 3, updated Fluent theming for WPF, and WinForms picks up Dark Mode support, modern icon APIs, and Control.InvokeAsync for cleaner asynchronous UI updates. None of this is transformative, but if you maintain a WinForms app that has been quietly ignored for years, Dark Mode support alone might be reason enough to move it to .NET 9.

C# 13 and F# 9: small language changes that remove real friction

C# 13’s most immediately useful change is that the params modifier now works with any supported collection type, not just arrays, so you can write params ReadOnlySpan<T> or params List<T> instead of being forced into array allocation every time. The other addition worth knowing about is System.Threading.Lock, a dedicated lock type that the compiler specifically recognizes.

Lock myLock = new();
 
void Concat<T>(params List<T> items)
{
    lock (myLock)
        Console.WriteLine(string.Join("Item: ", items));
}

For two decades, C# developers have locked on plain object references, which works through System.Threading.Monitor under the hood but carries risks: locking on a boxed value type, locking on a publicly accessible object another piece of code might also lock on, and generally getting no compiler help if you misuse it. Lock is a purpose-built type, and when you use the ordinary lock keyword on a variable typed as Lock, the compiler emits optimized code automatically. The syntax looks identical to what you already write, so migrating existing code is mostly a matter of changing the field type from object to Lock. The benefit only applies when the locked variable is actually typed as Lock, so locking on an old object field gets you the same Monitor-based behavior as before.

F# 9 adds nullable reference type checking for interop with C# libraries, which surfaces warnings like the one below when a nullable string flows into code that was not written to expect it.

// FS3261: Nullness warning: The types 'string' and 'string | null' do not have equivalent nullability.
let methodArgument (s: string | null) = File.Create s
 
let matchNullableString (s: string | null) =
    match s with
    | null -> 0
    | notNull -> notNull.Length

The first function triggers FS3261 because File.Create expects a non-null string but receives a value that F# now knows might be null, thanks to the nullability annotation on the parameter. The second function shows the fix pattern: match explicitly on null before touching the value, and F# narrows the type so notNull.Length compiles without a warning. This is opt-in and mainly matters if your F# code consumes C# libraries that use nullable reference type annotations, so pure F# codebases will see fewer of these warnings in practice.

Developer tools: the terminal logger overhaul is worth trying

Visual Studio 2022 17.12 brings closer Aspire integration, C# 13 analyzer support, and general performance and debugging improvements, alongside a large batch of user-requested fixes. The C# Dev Kit for VS Code improved editing reliability and NuGet package management, and its test adapter now reports coverage results more consistently, which matters if you have been avoiding the Dev Kit for serious .NET work because of flaky test discovery.

The change most developers will notice day to day is the redesigned terminal logger for the dotnet CLI. Build output is more condensed, errors and warnings are color-coded, links in the output are clickable in supporting terminals, and you get a summary count of total failures and warnings at the end of a build instead of having to scroll back through a wall of MSBuild output.

The redesigned dotnet CLI terminal logger, showing color-coded build output and a warning/error summary (source: Microsoft .NET Blog)
The redesigned dotnet CLI terminal logger, showing color-coded build output and a warning/error summary (source: Microsoft .NET Blog)

If you build .NET projects from the command line or CI logs regularly, this alone makes upgrading worthwhile, since scanning a condensed, color-coded build log takes noticeably less time than the old verbose MSBuild output.

NuGet: a real security feature, with a real gotcha

NuGet crossed 420,000 packages and over 570 billion downloads, and the ecosystem got a genuinely useful security addition in this release: dotnet restore can now audit both top-level and transitive dependencies for known vulnerabilities, using data from the GitHub Advisory Database. Paired with Central Package Management, this makes it realistic to spot and patch a vulnerable transitive dependency across every project in a large repository instead of hunting through each csproj by hand.

The gotcha is worth flagging explicitly: this new audit behavior produces warnings, and if your build pipeline has treat-warnings-as-errors enabled, upgrading to .NET 9 could start failing builds the moment a vulnerability is disclosed for a package you depend on, even if nothing in your code changed. Read through the audit documentation before you upgrade a CI pipeline that treats warnings as errors, so a Tuesday afternoon build failure does not catch your team by surprise.

Should you upgrade now

For side projects, greenfield services, and teams that already track STS releases, .NET 9 is a straightforward upgrade with real performance and tooling benefits, particularly the Server GC change and the terminal logger. For production systems currently on .NET 8, the calculus is different: .NET 8 is LTS with support into late 2026, and .NET 9’s 18-month support window means you would need to plan another migration to .NET 10 not long after adopting it. The AI abstractions and Aspire 9 are compelling reasons to experiment now, but experimenting is different from shipping, and the honest answer for most production LTS-tracking teams is to pilot .NET 9 in a non-critical service first.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading