Mert Özen Further With Every Line
Backend Real-time Applications — #09

What's New in Real-time from .NET 8 to .NET 11: Stateful Reconnect, Native AOT, SSE and SignalR Authentication Refresh

Mert Özen Nov 8, 2026 11 min 6 views
What's New in Real-time from .NET 8 to .NET 11: Stateful Reconnect, Native AOT, SSE and SignalR Authentication Refresh

.NET 8 brought stateful reconnect, .NET 9 polymorphic hub parameters, an ActivitySource and Native AOT for SignalR, .NET 10 TypedResults.ServerSentEvents, and .NET 11 adds authentication refresh that renews the token without dropping the connection. A 28 MB vs 82 MB AOT measurement, the strongly typed hub AOT warning, cancellable hub calls, SSE in OpenAPI 3.2 and legacy ASP.NET SignalR.

Version note: The code in this post was built and run with .NET 10 and @microsoft/signalr 10.0.11; the .NET 11 features are described from the documentation at the RC (Release Candidate) stage at the time of writing and were not tried as code.

Guess First

You publish a small SignalR hub with Native AOT (Ahead-of-Time): a single native executable that does not need the .NET runtime. How many MB do you think the file is? How much faster does it answer the first negotiate request than the same application running with the JIT (Just-in-Time) compiler? And: does the strongly typed hub (Hub<IClient>) we recommended in the fourth post of the series work with AOT?

This is the version post of the series. We go through what came to the real-time side from .NET 8 to .NET 11, tying it to what we saw in the earlier posts, and we try .NET 9's AOT support with a real measurement.

.NET 8: Stateful Reconnect

The big real-time addition in .NET 8 was stateful reconnect: AllowStatefulReconnects on the server, withStatefulReconnect() on the client. Both sides keep unacknowledged messages in a buffer, and when the transport drops briefly they continue with the same connection id. Let's repeat the result we measured in the seventh post of the series: in a fast 100-message broadcast with an instantaneous drop, the plain client lost one message and the stateful client lost none; but with a 2-second outage, because the JavaScript client tries to reconnect to the same connection only once, both continued on a new connection and lost the same messages. Stateful reconnect is for short transport drops; for long outages, application-level recovery is still your job.

.NET 8 also brought built-in metrics to ASP.NET Core; signalr.server.active_connections and signalr.server.connection.duration, which we read in the eighth post, have been there since this release.

.NET 9: Polymorphic Parameters, ActivitySource and KeepAliveTimeout

// .NET 9: a hub method can take a base type; the type comes from the "$type" field in the JSON
[JsonPolymorphic]
[JsonDerivedType(typeof(OrderShipped), "shipped")]
[JsonDerivedType(typeof(OrderCancelled), "cancelled")]
public abstract record OrderEvent(int OrderId);
public sealed record OrderShipped(int OrderId, string TrackingNumber) : OrderEvent(OrderId);
public sealed record OrderCancelled(int OrderId, string Reason) : OrderEvent(OrderId);

public sealed class OrderEventsHub : Hub
{
    public string Report(OrderEvent orderEvent) => orderEvent switch
    {
        OrderShipped shipped => $"order {shipped.OrderId} shipped with {shipped.TrackingNumber}",
        OrderCancelled cancelled => $"order {cancelled.OrderId} cancelled: {cancelled.Reason}",
        _ => "unknown event"
    };
}

Before .NET 9, a hub method parameter had to be a concrete type; write a base type and the fields of the derived type were lost. Now it can take a base type marked with System.Text.Json's polymorphism attributes. From the Node client:

console.log(await c.invoke('Report', { $type: 'shipped', orderId: 42, trackingNumber: 'TRK-42-001' }));
console.log(await c.invoke('Report', { $type: 'cancelled', orderId: 43, reason: 'Out of stock' }));
try { await c.invoke('Report', { orderId: 44 }); } catch (e) { console.log('no $type:', e.message); }
order 42 shipped with TRK-42-001
order 43 cancelled: Out of stock
no $type: Failed to invoke 'Report' due to an error on the server.

The client must send the $type field; a call without it fails on the server, and with detailed errors off the client only sees the generic message. This maps naturally onto discriminated unions on the TypeScript side: { $type: 'shipped', ... } | { $type: 'cancelled', ... }.

We used .NET 9's other two additions in earlier posts. An ActivitySource named Microsoft.AspNetCore.SignalR.Server for hub method calls, and Microsoft.AspNetCore.SignalR.Client for the .NET client; in the eighth post we saw the span for every hub call with the rpc.system=signalr tag. And for raw WebSockets, KeepAliveTimeout: the setting, off by default, that drops the connection when a ping gets no pong; we used it together with KeepAliveInterval in the second post.

.NET 9: Trimming and Native AOT for SignalR

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>
WebApplicationBuilder builder = WebApplication.CreateSlimBuilder(args);
builder.Services.AddSignalR();
builder.Services.Configure<JsonHubProtocolOptions>(options =>
    options.PayloadSerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));

WebApplication app = builder.Build();
app.MapHub<OrderEventsHub>("/hubs/order-events");
app.Run();

[JsonSerializable(typeof(OrderEvent))]
[JsonSerializable(typeof(string))]
internal partial class AppJsonContext : JsonSerializerContext;

Because reflection-based JSON (JavaScript Object Notation) serialization does not work under AOT, we declare every type the hub uses to a JsonSerializerContext source generator and put it at the head of the hub protocol's resolver chain. dotnet publish -c Release finished in 6 seconds on macOS arm64 and produced a single file:

-rwxr-xr-x@ 1 mertozen  wheel  10496712 Nov  8 05:59 AotHub
 Mach-O 64-bit executable arm64

This 10.5 MB file runs on a machine without the .NET runtime. The outputs of the polymorphic test above were also taken from this native file. I compared the time from starting the application until the first negotiate request is answered, and the physical memory at that moment, with the JIT-based Release output of the same project (twice each):

native first negotiate after 59 ms, rss 27936 KB
jit    first negotiate after 869 ms, rss 82768 KB
native first negotiate after 57 ms, rss 27936 KB
jit    first negotiate after 107 ms, rss 81696 KB

The native version answered in about 58 ms in both runs and used 28 MB of memory. The JIT version's first run took 869 ms (the files are read from disk for the first time) and the second 107 ms; memory was around 82 MB both times. For an idle hub that is close to a threefold difference in memory; meaningful if you run many small real-time services in containers or need to scale out fast.

The answer to the last opening question: no. When I made the same hub a Hub<IOrderEventsClient>, publish gave these warnings:

ILC : AOT analysis warning IL3050: OrderEventsHub.OrderEventsHub(): Using member 'Microsoft.AspNetCore.SignalR.Hub`1<IOrderEventsClient>.Hub`1()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating a proxy instance requires generating code at runtime. [AotTyped.csproj]
Program.cs(23,21): warning IL3050: Using member 'Microsoft.AspNetCore.SignalR.Hub<T>.Hub()' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Creating a proxy instance requires generating code at runtime. [AotTyped.csproj]

A strongly typed hub generates a proxy class for the client interface at runtime; that is not possible under Native AOT. Microsoft's documentation lists the limitations clearly: under AOT only the JSON protocol (no MessagePack), no strongly typed hubs (they work with trimming), no IAsyncEnumerable<T> and ChannelReader<T> parameters with value-type (struct) items, and only Task, Task<T>, ValueTask and ValueTask<T> as async return types. If you move to AOT, use the classic Hub with SendAsync("MethodName", ...) and keep method names in constants. While building I also saw NuGet audit raise a security warning (NU1903) for the Microsoft.DotNet.ILCompiler 10.0.13 package brought in by the SDK (Software Development Kit); if you publish with AOT, keep your SDK up to date.

.NET 10: TypedResults.ServerSentEvents

.NET 10's contribution to the real-time side was SSE (Server-Sent Events): TypedResults.ServerSentEvents takes an IAsyncEnumerable<T> or IAsyncEnumerable<SseItem<T>> and turns it into an event stream with the right headers, and SseItem<T> in System.Net.ServerSentEvents gained the EventId and ReconnectionInterval properties. We introduced it in the ".NET 10 What's New" post of the ASP.NET Core Web API (Application Programming Interface) series; in the third post of this series we tried details such as resuming with Last-Event-ID, a proxy returning 504 because the first byte went out late, and Nginx's gzip being unable to break the stream thanks to Content-Encoding: identity. In short: in .NET 10, for one-way notifications you can now use plain HTTP (Hypertext Transfer Protocol) with framework support, without needing SignalR.

.NET 11: SignalR Authentication Refresh

In the sixth post of the series we saw SignalR's most annoying security gap: the user is authenticated once, when connecting, and the connection stays open even after the token expires; close it with CloseOnAuthenticationExpiration and the user pays with a drop, missed messages and lost groups. .NET 11 brings a third option: renewing the token without dropping the connection.

According to the .NET 11 documentation (page dated 19 October 2026, RC period), the mechanism works like this: EnableAuthenticationRefresh is turned on in the options of MapHub, and Microsoft recommends turning it on together with CloseOnAuthenticationExpiration, so a connection that is not refreshed is closed when it expires. The server exposes a /refresh endpoint next to /negotiate and reports the token's remaining lifetime in the negotiate response. The client goes to this endpoint with a new token before the lifetime runs out; it identifies the connection with the connection token, and on success the hub's Context.User is updated with the new identity. You can override OnAuthenticationRefreshedAsync in the hub, and the OnAuthenticationRefresh callback in the options can compare the old and new identities and reject the refresh; by default the new identity must map to the same SignalR user, otherwise it is a 403.

On the client side, automatic refresh is on by default in the .NET client and configured with WithAuthenticationRefresh (e.g. how long before expiry to refresh); there are AuthenticationRefreshed and AuthenticationRefreshFailed events, and RefreshAuthenticationAsync to trigger it manually. In the TypeScript client the counterparts are withAuthenticationRefresh, onAuthenticationRefreshed, onAuthenticationRefreshFailed and refreshAuthentication. A failed refresh does not close the connection on its own and is not retried automatically; the connection closes when the token expires. Two practical points the documentation stresses: add the /refresh route to reverse proxy, WAF (Web Application Firewall) and CORS (Cross-Origin Resource Sharing) rules (a proxy that lets negotiate through but blocks refresh makes every refresh fail), and send the user back to login in onAuthenticationRefreshFailed. At the time of writing, the next tag of @microsoft/signalr on npm was 11.0.0-preview.7 and latest was 10.0.11; the API names should be verified against the GA (General Availability) release.

.NET 11: Cancellable Hub Calls and SSE in OpenAPI 3.2

Up to .NET 10, the client could only cancel streaming calls (in the seventh post we triggered the server-side CancellationToken with dispose()). A token passed to an ordinary InvokeAsync call made the client stop waiting, but the method on the server kept running. According to the .NET 11 documentation, when the token passed to the client's InvokeAsync is cancelled, the client now sends a cancel message to the server and the hub method's CancellationToken parameter fires. For a long-running report or search method, it means the server-side work also stops when the user presses "cancel"; adding a CancellationToken parameter to your hub methods now gets you this behaviour for free once you move to .NET 11.

On the SSE side, .NET 11 makes OpenAPI 3.2 the default document version and describes endpoints returning SseItem<T> with 3.2's itemSchema field for the text/event-stream response. In earlier versions the document only said "a stream of strings"; now the schema of each event's data field (e.g. #/components/schemas/Todo) and the event and id fields appear in the document, and client generators can know the stream's type. An important note from the documentation: the stream must be returned through TypedResults.ServerSentEvents; if a handler returns IAsyncEnumerable<SseItem<T>> directly, it is serialized as JSON, not as SSE.

Recognising Legacy ASP.NET SignalR and Migration Notes

If you see "SignalR" in a .NET Framework application you inherited at a company, it is most likely not the SignalR of this series but ASP.NET SignalR from 2013. They share a name but their protocols are incompatible: the ASP.NET Core SignalR client cannot connect to the old server, and the old client cannot connect to the new one. To recognise it:

TopicASP.NET SignalR (.NET Framework)ASP.NET Core SignalR
NuGet packageMicrosoft.AspNet.SignalRPart of ASP.NET Core
Server registrationOWIN (Open Web Interface for .NET) app.MapSignalR()AddSignalR() + MapHub<T>()
JavaScript clientjQuery-based $.connection, auto-generated /signalr/hubs proxy@microsoft/signalr, no jQuery
Sending from outside the hubGlobalHost.ConnectionManager.GetHubContext<T>()IHubContext<T> via DI (Dependency Injection)
ReconnectingAutomatic, the server buffers messageswithAutomaticReconnect, optional stateful reconnect
Scale-outRedis, SQL (Structured Query Language) Server, Service Bus backplanesRedis backplane, Azure SignalR Service

The differences that hurt most in a migration are behavioural. The old version handled reconnecting and message buffering by itself; in the new one you set them up deliberately, as in the seventh post of the series. In the old version the client called hub methods through proxies defined on the server; in the new one it calls them as strings with invoke('MethodName'). If both versions must run at the same time (old versions of mobile apps, for example), running them side by side on separate endpoints and retiring old clients over time is the least risky path.

Between .NET 8, 9 and 10, on the other hand, I hit no breaking change on the real-time side; every example in this series ran on .NET 10 with default settings. What to look at when upgrading is behavioural defaults: new settings such as KeepAliveTimeout arrive turned off, and turning them on is your decision.

Production Notes

When upgrading, think about client and server packages together. Features such as stateful reconnect and authentication refresh need both sides to support them; upgrading the server while the mobile app stays on an old @microsoft/signalr version means the feature silently does not kick in. The negotiate response tells you which features were agreed; it is the first place to look when investigating a problem.

Treat AOT not as a performance switch but as a set of constraints. 28 MB versus 82 MB and a start-up measured in milliseconds are real gains, but they require giving up strongly typed hubs, MessagePack and libraries that use reflection. Treat IL3050 and IL2026 warnings in the build as errors; they are the only advance warning of code that will blow up at runtime under AOT.

If you plan to use .NET 11's authentication refresh, review proxy and WAF rules, CORS policies and rate limits for /refresh now; the shorter your token lifetime, the more requests that endpoint will get.

Common Pitfalls

Turning on stateful reconnect and assuming message loss is over. It only covers short drops that recover instantly.

Moving to Native AOT and keeping the strongly typed hub. Publish warns, and you get an error at runtime.

Forgetting that the client must send $type for a polymorphic hub parameter. The call fails on the server.

Taking .NET 11 features to production based on the RC documentation. API names and behaviours may change at GA; verify against the GA release notes.

Trying to connect a legacy ASP.NET SignalR client to the new server. The protocols are incompatible; both sides have to move together.

Relying on .NET 11's cancellation support without adding a CancellationToken to hub methods. The cancel message arrives, but with no token listening the work continues.

A Small Experiment

★☆☆ Add a third event type to OrderEventsHub with [JsonDerivedType(typeof(OrderDelayed), "delayed")] and publish again with AOT. Did you need to add anything to AppJsonContext?

★★☆ Publish the same hub with <PublishTrimmed>true</PublishTrimmed> (without AOT) and compare, against the Native AOT output, whether you get a warning this time with Hub<IOrderEventsClient>, the file size and the start-up time.

★★★ When .NET 11 GA ships, update the example from the sixth post with EnableAuthenticationRefresh and withAuthenticationRefresh in the TypeScript client: with 30-second tokens, verify that the connection never drops for two minutes and that the token expiry time returned by WhoAmI moves forward.

This is where the series ends. Across nine posts we went from polling to raw WebSockets, SSE, SignalR's hub, group, security and resilience layers, and on to multiple servers. The next step is to combine these pieces in your own application: first ask "does this feature really need to be real-time?", then pick the simplest technique that fits.