What's new in ASP.NET Core 9.0

This article highlights the most significant changes in ASP.NET Core 9.0 with links to relevant documentation.

This article has been updated for .NET 9 Preview 4.


This section describes new features for Blazor.

Add static server-side rendering (SSR) pages to a globally-interactive Blazor Web App

With the release of .NET 9, it's now simpler to add static SSR pages to apps that adopt global interactivity.

This approach is only useful when the app has specific pages that can't work with interactive Server or WebAssembly rendering. For example, adopt this approach for pages that depend on reading/writing HTTP cookies and can only work in a request/response cycle instead of interactive rendering. For pages that work with interactive rendering, you shouldn't force them to use static SSR rendering, as it's less efficient and less responsive for the end user.

Mark any Razor component page with the new [ExcludeFromInteractiveRouting] attribute assigned with the @attribute Razor directive:

@attribute [ExcludeFromInteractiveRouting]

Applying the attribute causes navigation to the page to exit from interactive routing. Inbound navigation is forced to perform a full-page reload instead resolving the page via interactive routing. The full-page reload forces the top-level root component, typically the App component (App.razor), to rerender from the server, allowing the app to switch to a different top-level render mode.

The HttpContext.AcceptsInteractiveRouting extension method allows the component to detect whether [ExcludeFromInteractiveRouting] is applied to the current page.

In the App component, use the pattern in the following example:

  • Pages that aren't annotated with [ExcludeFromInteractiveRouting] default to the InteractiveServer render mode with global interactivity. You can replace InteractiveServer with InteractiveWebAssembly or InteractiveAuto to specify a different default global render mode.
  • Pages annotated with [ExcludeFromInteractiveRouting] adopt static SSR (PageRenderMode is assigned null).
<!DOCTYPE html>
    <HeadOutlet @rendermode="@PageRenderMode" />
    <Routes @rendermode="@PageRenderMode" />

@code {
    private HttpContext HttpContext { get; set; } = default!;

    private IComponentRenderMode? PageRenderMode
        => HttpContext.AcceptsInteractiveRouting() ? InteractiveServer : null;

An alternative to using the HttpContext.AcceptsInteractiveRouting extension method is to read endpoint metadata manually using HttpContext.GetEndpoint()?.Metadata.

This feature is covered by the reference documentation in ASP.NET Core Blazor render modes.

Constructor injection

Razor components support constructor injection.

In the following example, the partial (code-behind) class injects the NavigationManager service using a primary constructor:

public partial class ConstructorInjection(NavigationManager navigation)
    protected NavigationManager Navigation { get; } = navigation;

For more information, see ASP.NET Core Blazor dependency injection.

Websocket compression for Interactive Server components

By default, Interactive Server components enable compression for WebSocket connections and set a frame-ancestors Content Security Policy (CSP) directive set to 'self', which only permits embedding the app in an <iframe> of the origin from which the app is served when compression is enabled or when a configuration for the WebSocket context is provided.

Compression can be disabled by setting ConfigureWebSocketOptions to null, which reduces the vulnerability of the app to attack but may result in reduced performance:

.AddInteractiveServerRenderMode(o => o.ConfigureWebSocketOptions = null)

Configure a stricter frame-ancestors CSP with a value of 'none' (single quotes required), which allows WebSocket compression but prevents browsers from embedding the app into any <iframe>:

.AddInteractiveServerRenderMode(o => o.ContentSecurityFrameAncestorsPolicy = "'none'")

For more information, see the following resources:

Handle keyboard composition events in Blazor

The new KeyboardEventArgs.IsComposing property indicates if the keyboard event is part of a composition session. Tracking the composition state of keyboard events is crucial for handling international character input methods.

Added OverscanCount parameter to QuickGrid

The QuickGrid component now exposes an OverscanCount property that specifies how many additional rows are rendered before and after the visible region when virtualization is enabled.

The default OverscanCount is 3. The following example increases the OverscanCount to 4:

<QuickGrid ItemsProvider="itemsProvider" Virtualize="true" OverscanCount="4">


This section describes new features for SignalR.

Polymorphic type support in SignalR Hubs

Hub methods can now accept a base class instead of the derived class to enable polymorphic scenarios. The base type needs to be annotated to allow polymorphism.

public class MyHub : Hub
    public void Method(JsonPerson person)
        if (person is JsonPersonExtended)
        else if (person is JsonPersonExtended2)

[JsonDerivedType(typeof(JsonPersonExtended), nameof(JsonPersonExtended))]
[JsonDerivedType(typeof(JsonPersonExtended2), nameof(JsonPersonExtended2))]
private class JsonPerson
    public string Name { get; set; }
    public Person Child { get; set; }
    public Person Parent { get; set; }

private class JsonPersonExtended : JsonPerson
    public int Age { get; set; }

private class JsonPersonExtended2 : JsonPerson
    public string Location { get; set; }

Minimal APIs

This section describes new features for minimal APIs.

Added InternalServerError and InternalServerError<TValue> to TypedResults

The TypedResults class is a helpful vehicle for returning strongly-typed HTTP status code-based responses from a minimal API. TypedResults now includes factory methods and types for returning "500 Internal Server Error" responses from endpoints. Here's an example that returns a 500 response:

var app = WebApplication.Create();

app.MapGet("/", () => TypedResults.InternalServerError("Something went wrong!"));



Built-in support for OpenAPI document generation

The OpenAPI specification is a standard for describing HTTP APIs. The standard allows developers to define the shape of APIs that can be plugged into client generators, server generators, testing tools, documentation, and more. In .NET 9 Preview, ASP.NET Core provides built-in support for generating OpenAPI documents representing controller-based or minimal APIs via the Microsoft.AspNetCore.OpenApi package.

The following highlighted code calls:

  • AddOpenApi to register the required dependencies into the app's DI container.
  • MapOpenApi to register the required OpenAPI endpoints in the app's routes.
var builder = WebApplication.CreateBuilder();


var app = builder.Build();


app.MapGet("/hello/{name}", (string name) => $"Hello {name}"!);


Install the Microsoft.AspNetCore.OpenApi package in the project using the following command:

dotnet add package Microsoft.AspNetCore.OpenApi --prerelease

Run the app and navigate to openapi/v1.json to view the generated OpenAPI document:

OpenAPI document

OpenAPI documents can also be generated at build-time by adding the Microsoft.Extensions.ApiDescription.Server package:

dotnet add package Microsoft.Extensions.ApiDescription.Server --prerelease

In the app's project file, add the following:


Run dotnet build and inspect the generated JSON file in the project directory.

OpenAPI document generation at build-time

ASP.NET Core's built-in OpenAPI document generation provides support for various customizations and options. It provides document and operation transformers and has the ability to manage multiple OpenAPI documents for the same application.

To learn more about ASP.NET Core's new OpenAPI document capabilities, see the new Microsoft.AspNetCore.OpenApi docs.

Authentication and authorization

This section describes new features for authentication and authorization.

OIDC and OAuth Parameter Customization

The OAuth and OIDC authentication handlers now have an AdditionalAuthorizationParameters option to make it easier to customize authorization message parameters that are usually included as part of the redirect query string. In .NET 8 and earlier, this requires a custom OnRedirectToIdentityProvider callback or overridden BuildChallengeUrl method in a custom handler. Here's an example of .NET 8 code:

builder.Services.AddAuthentication().AddOpenIdConnect(options =>
    options.Events.OnRedirectToIdentityProvider = context =>
        context.ProtocolMessage.SetParameter("prompt", "login");
        context.ProtocolMessage.SetParameter("audience", "https://api.example.com");
        return Task.CompletedTask;

The preceding example can now be simplified to the following code:

builder.Services.AddAuthentication().AddOpenIdConnect(options =>
    options.AdditionalAuthorizationParameters.Add("prompt", "login");
    options.AdditionalAuthorizationParameters.Add("audience", "https://api.example.com");

Configure HTTP.sys extended authentication flags

You can now configure the HTTP_AUTH_EX_FLAG_ENABLE_KERBEROS_CREDENTIAL_CACHING and HTTP_AUTH_EX_FLAG_CAPTURE_CREDENTIAL HTTP.sys flags by using the new EnableKerberosCredentialCaching and CaptureCredentials properties on the HTTP.sys AuthenticationManager to optimize how Windows authentication is handled. For example:

webBuilder.UseHttpSys(options =>
    options.Authentication.Schemes = AuthenticationSchemes.Negotiate;
    options.Authentication.EnableKerberosCredentialCaching = true;
    options.Authentication.CaptureCredentials = true;


The following sections describe miscellaneous new features.

New HybridCache library

The HybridCache API bridges some gaps in the existing IDistributedCache and IMemoryCache APIs. It also adds new capabilities, such as:

  • "Stampede" protection to prevent parallel fetches of the same work.
  • Configurable serialization.

HybridCache is designed to be a drop-in replacement for existing IDistributedCache and IMemoryCache usage, and it provides a simple API for adding new caching code. It provides a unified API for both in-process and out-of-process caching.

To see how the HybridCache API is simplified, compare it to code that uses IDistributedCache. Here's an example of what using IDistributedCache looks like:

public class SomeService(IDistributedCache cache)
    public async Task<SomeInformation> GetSomeInformationAsync
        (string name, int id, CancellationToken token = default)
        var key = $"someinfo:{name}:{id}"; // Unique key for this combination.
        var bytes = await cache.GetAsync(key, token); // Try to get from cache.
        SomeInformation info;
        if (bytes is null)
            // Cache miss; get the data from the real source.
            info = await SomeExpensiveOperationAsync(name, id, token);

            // Serialize and cache it.
            bytes = SomeSerializer.Serialize(info);
            await cache.SetAsync(key, bytes, token);
            // Cache hit; deserialize it.
            info = SomeSerializer.Deserialize<SomeInformation>(bytes);
        return info;

    // This is the work we're trying to cache.
    private async Task<SomeInformation> SomeExpensiveOperationAsync(string name, int id,
        CancellationToken token = default)
    { /* ... */ }

That's a lot of work to get right each time, including things like serialization. And in the cache miss scenario, you could end up with multiple concurrent threads, all getting a cache miss, all fetching the underlying data, all serializing it, and all sending that data to the cache.

To simplify and improve this code with HybridCache, we first need to add the new library Microsoft.Extensions.Caching.Hybrid:

<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" Version="9.0.0" />

Register the HybridCache service, like you would register an IDistributedCache implementation:

services.AddHybridCache(); // Not shown: optional configuration API.

Now most caching concerns can be offloaded to HybridCache:

public class SomeService(HybridCache cache)
    public async Task<SomeInformation> GetSomeInformationAsync
        (string name, int id, CancellationToken token = default)
        return await cache.GetOrCreateAsync(
            $"someinfo:{name}:{id}", // Unique key for this combination.
            async cancel => await SomeExpensiveOperationAsync(name, id, cancel),
            token: token

We provide a concrete implementation of the HybridCache abstract class via dependency injection, but it's intended that developers can provide custom implementations of the API. The HybridCache implementation deals with everything related to caching, including concurrent operation handling. The cancel token here represents the combined cancellation of all concurrent callers—not just the cancellation of the caller we can see (that is, token).

High throughput scenarios can be further optimized by using the TState pattern, to avoid some overhead from captured variables and per-instance callbacks:

public class SomeService(HybridCache cache)
    public async Task<SomeInformation> GetSomeInformationAsync(string name, int id, CancellationToken token = default)
        return await cache.GetOrCreateAsync(
            $"someinfo:{name}:{id}", // unique key for this combination
            (name, id), // all of the state we need for the final call, if needed
            static async (state, token) =>
                await SomeExpensiveOperationAsync(state.name, state.id, token),
            token: token

HybridCache uses the configured IDistributedCache implementation, if any, for secondary out-of-process caching, for example, using Redis. But even without an IDistributedCache, the HybridCache service will still provide in-process caching and "stampede" protection.

A note on object reuse

In typical existing code that uses IDistributedCache, every retrieval of an object from the cache results in deserialization. This behavior means that each concurrent caller gets a separate instance of the object, which cannot interact with other instances. The result is thread safety, as there's no risk of concurrent modifications to the same object instance.

Because a lot of HybridCache usage will be adapted from existing IDistributedCache code, HybridCache preserves this behavior by default to avoid introducing concurrency bugs. However, a given use case is inherently thread-safe:

  • If the types being cached are immutable.
  • If the code doesn't modify them.

In such cases, inform HybridCache that it's safe to reuse instances by:

  • Marking the type as sealed. The sealed keyword in C# means that the class cannot be inherited.
  • Applying the [ImmutableObject(true)] attribute to it. Yhe [ImmutableObject(true)] attribute indicates that the object's state cannot be changed after it's created.

By reusing instances, HybridCache can reduce the overhead of CPU and object allocations associated with per-call deserialization. This can lead to performance improvements in scenarios where the cached objects are large or accessed frequently.

Other HybridCache features

Like IDistributedCache, HybridCache supports removal by key with a RemoveKeyAsync method.

HybridCache also provides optional APIs for IDistributedCache implementations, to avoid byte[] allocations. This feature is implemented by the preview versions of the Microsoft.Extensions.Caching.StackExchangeRedis and Microsoft.Extensions.Caching.SqlServer packages.

Serialization is configured as part of registering the service, with support for type-specific and generalized serializers via the WithSerializer and .WithSerializerFactory methods, chained from the AddHybridCache call. By default, the library handles string and byte[] internally, and uses System.Text.Json for everything else, but you can use protobuf, xml, or anything else.

HybridCache supports older .NET runtimes, down to .NET Framework 4.7.2 and .NET Standard 2.0.

For more information about HybridCache, see HybridCache library in ASP.NET Core

Developer exception page improvements

The ASP.NET Core developer exception page is displayed when an app throws an unhandled exception during development. The developer exception page provides detailed information about the exception and request.

Preview 3 added endpoint metadata to the developer exception page. ASP.NET Core uses endpoint metadata to control endpoint behavior, such as routing, response caching, rate limiting, OpenAPI generation, and more. The following image shows the new metadata information in the Routing section of the developer exception page:

The new metadata information on the developer exception page

While testing the developer exception page, small quality of life improvements were identified. They shipped in Preview 4:

  • Better text wrapping. Long cookies, query string values, and method names no longer add horizontal browser scroll bars.
  • Bigger text which is found in modern designs.
  • More consistent table sizes.

The following animated image shows the new developer exception page:

The new developer exception page

Dictionary debugging improvements

The debugging display of dictionaries and other key-value collections has an improved layout. The key is displayed in the debugger's key column instead of being concatenated with the value. The following images show the old and new display of a dictionary in the debugger.


The previous debugger experience


The new debugger experience

ASP.NET Core has many key-value collections. This improved debugging experience applies to:

  • HTTP headers
  • Query strings
  • Forms
  • Cookies
  • View data
  • Route data
  • Features

Fix for 503's during app recycle in IIS

By default there is now a 1 second delay between when IIS is notified of a recycle or shutdown and when ANCM tells the managed server to start shutting down. The delay is configurable via the ANCM_shutdownDelay environment variable or by setting the shutdownDelay handler setting. Both values are in milliseconds. The delay is mainly to reduce the likelihood of a race where:

  • IIS hasn't started queuing requests to go to the new app.
  • ANCM starts rejecting new requests that come into the old app.

Slower machines or machines with heavier CPU usage may want to adjust this value to reduce 503 likelihood.

Example of setting shutdownDelay:

<aspNetCore processPath="dotnet" arguments="myapp.dll" stdoutLogEnabled="false" stdoutLogFile=".logsstdout">
    <!-- Milliseconds to delay shutdown by.
    this doesn't mean incoming requests will be delayed by this amount,
    but the old app instance will start shutting down after this timeout occurs -->
    <handlerSetting name="shutdownDelay" value="5000" />

The fix is in the globally installed ANCM module that comes from the hosting bundle.