SignalR on .NET 10 — Real-Time Communication, Scale-Out, and Notification Push for Production

Posted on: 4/17/2026 9:10:35 PM

In the modern web, users expect everything to be instant — chat messages appear immediately, dashboards update in real time, notifications push in without a page refresh. SignalR on ASP.NET Core is the production-ready solution for building these real-time features on the .NET platform, with automatic transport selection, strongly-typed hubs, and the ability to scale out to millions of connections. This article takes a deep look at SignalR architecture on .NET 10, battle-tested patterns, scale-out strategies with Redis Backplane and Azure SignalR Service, and comparisons with SSE and raw WebSocket.

1. SignalR — The real-time framework for .NET

ASP.NET Core SignalR is an open-source library that simplifies adding real-time functionality to web applications. Instead of having to manage WebSocket connections, heartbeats, reconnection, and fallback transports yourself, SignalR abstracts all that complexity behind the concept of a Hub — a high-level API that lets the server invoke methods on the client and vice versa.

<1ms Latency over the WebSocket transport
3 Auto transports: WS, SSE, Long Polling
1M+ Connections with Azure SignalR Service
0 Lines of manual connection-management code

1.1. The Hub architecture — the communication hub

The Hub is SignalR's core concept. It acts as a bidirectional pipeline between server and client. The server can call methods on any client (or group of clients), and the client can invoke methods on the server — all over a single connection.

graph TB
    C1["Client 1
(Browser)"] -->|"WebSocket"| HUB["SignalR Hub
(ASP.NET Core)"] C2["Client 2
(Mobile)"] -->|"SSE Fallback"| HUB C3["Client 3
(Desktop)"] -->|"WebSocket"| HUB HUB -->|"SendAsync"| C1 HUB -->|"SendAsync"| C2 HUB -->|"SendAsync"| C3 HUB --> GROUPS["Groups
(room-123, admin)"] HUB --> USERS["Users
(userId mapping)"] GROUPS -->|"Group Broadcast"| C1 GROUPS -->|"Group Broadcast"| C3 style HUB fill:#e94560,stroke:#fff,color:#fff style C1 fill:#2c3e50,stroke:#e94560,color:#fff style C2 fill:#2c3e50,stroke:#e94560,color:#fff style C3 fill:#2c3e50,stroke:#e94560,color:#fff style GROUPS fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style USERS fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Figure 1: SignalR Hub architecture — server and client communicate bidirectionally over a single connection

// Defining a Hub on the server — .NET 10
public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        // Send to ALL connected clients
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }

    public async Task JoinRoom(string roomName)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, roomName);
        await Clients.Group(roomName)
            .SendAsync("UserJoined", Context.User?.Identity?.Name);
    }

    public override async Task OnConnectedAsync()
    {
        // Automatically fires when a client connects
        await Clients.Caller.SendAsync("Connected", Context.ConnectionId);
        await base.OnConnectedAsync();
    }
}

1.2. Strongly-Typed Hubs — Type safety both ways

One of the most powerful features of SignalR on .NET is the Strongly-Typed Hub. Instead of using magic strings like "ReceiveMessage", you define an interface for the client methods — the compiler checks it at build time, so runtime errors from typos disappear.

// Define the contract for client methods
public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
    Task UserJoined(string userName);
    Task UserLeft(string userName);
    Task TypingIndicator(string userName, bool isTyping);
}

// A strongly-typed Hub — the compiler checks every SendAsync call
public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string user, string message)
    {
        // Clients.All returns IChatClient — full IntelliSense
        await Clients.All.ReceiveMessage(user, message);
    }

    public async Task StartTyping()
    {
        var userName = Context.User?.Identity?.Name ?? "Anonymous";
        await Clients.Others.TypingIndicator(userName, true);
    }
}

Why use Strongly-Typed Hubs?

With a strongly-typed Hub, if you rename ReceiveMessage to OnMessageReceived on the interface but forget to update the JavaScript client, the compiler flags the error immediately. This matters most in large projects with many Hub methods — you avoid runtime typo errors that only surface in manual testing.

2. Transport protocols — WebSocket, SSE, and Long Polling

SignalR automatically negotiates the best transport at connection time. The process runs in two steps: (1) the client sends POST /negotiate to fetch the list of transports the server supports, (2) the client picks the best transport that both sides support.

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: POST /chat/negotiate
    S-->>C: {connectionId, transports: [WS, SSE, LP]}
    alt WebSocket available
        C->>S: Upgrade: websocket
        S-->>C: 101 Switching Protocols
        Note over C,S: Full-duplex communication
    else SSE fallback
        C->>S: GET /chat?transport=SSE
        S-->>C: text/event-stream (server→client)
        C->>S: POST /chat (client→server)
    else Long Polling last resort
        C->>S: GET /chat?transport=LongPolling
        Note over C,S: Server holds the request until data is available
        S-->>C: Response with data
        C->>S: GET (poll again)
    end

Figure 2: Transport negotiation — SignalR auto-selects the optimal protocol

TransportDirectionLatencyBrowser supportWhen to use
WebSocketFull-duplexLowest (~1 ms)Every modern browserDefault — chat, games, collaboration
Server-Sent EventsServer → ClientLowAll (except IE)Dashboards, notifications, progress bars
Long PollingEmulated duplexHigh (poll interval)Every browserLast-resort fallback (corporate proxies)

2.1. Server-Sent Events on .NET 10 — Major improvement

.NET 10 adds Results.ServerSentEvents — a new API that wraps IAsyncEnumerable<T> into a standard SSE response without any extra library. Combined with Kestrel's HTTP/2 support, SSE on .NET 10 sidesteps the 6-connections-per-domain limit (HTTP/1.1) — multiple SSE streams can share a single TCP connection via HTTP/2 multiplexing.

// .NET 10 — SSE with Minimal API (no SignalR needed)
app.MapGet("/dashboard/stream", (
    DashboardService dashboard,
    CancellationToken ct) =>
{
    async IAsyncEnumerable<DashboardUpdate> StreamUpdates(
        [EnumeratorCancellation] CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            yield return await dashboard.GetLatestMetricsAsync(token);
            await Task.Delay(TimeSpan.FromSeconds(2), token);
        }
    }

    return Results.ServerSentEvents(StreamUpdates(ct));
});

SignalR or plain SSE?

If your real-time feature only needs one-way server push (dashboard metrics, notification feeds, progress bars), SSE with Results.ServerSentEvents on .NET 10 is the simpler choice — no Hub, no client library, no sticky sessions, no backplane. SignalR wins when you need two-way communication (chat, collaborative editing, game state sync) or complex Groups/Users management.

3. Battle-tested patterns with SignalR

3.1. Chat Room with Groups and presence

This is the classic SignalR use case. Groups let you broadcast to a subset of clients, while presence tracking monitors who's online.

public class ChatHub : Hub<IChatClient>
{
    private static readonly ConcurrentDictionary<string, UserInfo> _onlineUsers = new();

    public override async Task OnConnectedAsync()
    {
        var userId = Context.UserIdentifier!;
        var userName = Context.User!.FindFirst("name")!.Value;

        _onlineUsers.TryAdd(Context.ConnectionId, new UserInfo(userId, userName));

        // Broadcast the online user list to the newcomer
        await Clients.Caller.OnlineUsers(_onlineUsers.Values.ToList());
        await Clients.Others.UserJoined(userName);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        if (_onlineUsers.TryRemove(Context.ConnectionId, out var user))
            await Clients.Others.UserLeft(user.Name);
    }

    public async Task SendToRoom(string room, string message)
    {
        var sender = _onlineUsers[Context.ConnectionId];
        var chatMessage = new ChatMessage(sender.Name, message, DateTime.UtcNow);

        await Clients.Group(room).ReceiveMessage(chatMessage);
    }

    public async Task JoinRoom(string room)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, room);
        var sender = _onlineUsers[Context.ConnectionId];
        await Clients.Group(room).UserJoined(sender.Name);
    }

    public async Task LeaveRoom(string room)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, room);
        var sender = _onlineUsers[Context.ConnectionId];
        await Clients.Group(room).UserLeft(sender.Name);
    }
}

3.2. Real-time dashboard with a background service

In many cases, real-time data doesn't originate from the client — it comes from a background process: order processing workers, metric collectors, event consumers. SignalR lets you inject IHubContext into any service to push data to clients.

graph LR
    DB["Database
Event Store"] -->|"Change Event"| BG["Background
Service"] KAFKA["Kafka / Redis
Stream"] -->|"Consume"| BG BG -->|"IHubContext"| HUB["SignalR Hub"] HUB -->|"Push Update"| D1["Dashboard 1"] HUB -->|"Push Update"| D2["Dashboard 2"] HUB -->|"Push Update"| D3["Mobile App"] style HUB fill:#e94560,stroke:#fff,color:#fff style BG fill:#2c3e50,stroke:#e94560,color:#fff style DB fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style KAFKA fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style D1 fill:#2c3e50,stroke:#e94560,color:#fff style D2 fill:#2c3e50,stroke:#e94560,color:#fff style D3 fill:#2c3e50,stroke:#e94560,color:#fff

Figure 3: Push data from a background service to dashboard clients via IHubContext

// A background service pushes metrics to dashboards every 2 seconds
public class MetricsPushService : BackgroundService
{
    private readonly IHubContext<DashboardHub, IDashboardClient> _hubContext;
    private readonly IMetricsCollector _metrics;

    public MetricsPushService(
        IHubContext<DashboardHub, IDashboardClient> hubContext,
        IMetricsCollector metrics)
    {
        _hubContext = hubContext;
        _metrics = metrics;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            var snapshot = await _metrics.CollectAsync(ct);

            // Push to every client viewing the dashboard
            await _hubContext.Clients
                .Group("dashboard-viewers")
                .MetricsUpdated(new DashboardSnapshot
                {
                    ActiveUsers = snapshot.ActiveUsers,
                    RequestsPerSecond = snapshot.RPS,
                    ErrorRate = snapshot.ErrorRate,
                    P99Latency = snapshot.P99,
                    Timestamp = DateTime.UtcNow
                });

            await Task.Delay(TimeSpan.FromSeconds(2), ct);
        }
    }
}

3.3. Targeted notifications — right person, right time

SignalR supports sending a message to a specific user via Clients.User(userId). The user identifier is mapped automatically from ClaimTypes.NameIdentifier on the JWT token or cookie authentication.

// Push a real-time notification to a user from anywhere in the app
public class OrderService
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hub;

    public async Task CompleteOrderAsync(Order order)
    {
        await _repository.UpdateStatusAsync(order.Id, OrderStatus.Completed);

        // Real-time push to the user (across all open tabs/devices)
        await _hub.Clients.User(order.UserId).OrderStatusChanged(new OrderNotification
        {
            OrderId = order.Id,
            Status = "Completed",
            Message = $"Order #{order.Id} is complete!",
            Timestamp = DateTime.UtcNow
        });
    }
}

User vs ConnectionId

Clients.User(userId) sends to all connections belonging to a user (multiple tabs, multiple devices). Clients.Client(connectionId) only sends to one specific connection. For most notification use cases, use User() — a user with the app open on both laptop and phone receives the notification on both.

4. SignalR client — JavaScript and Vue.js

On the client side, SignalR ships the @microsoft/signalr library for JavaScript/TypeScript. Vue.js integration is clean with a composable pattern.

// composables/useSignalR.ts — A Vue 3 composable for SignalR
import { ref, onMounted, onUnmounted } from 'vue'
import * as signalR from '@microsoft/signalr'

export function useSignalR(hubUrl: string) {
  const connection = ref<signalR.HubConnection | null>(null)
  const isConnected = ref(false)

  const start = async () => {
    connection.value = new signalR.HubConnectionBuilder()
      .withUrl(hubUrl, {
        accessTokenFactory: () => localStorage.getItem('token') || ''
      })
      .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
      .configureLogging(signalR.LogLevel.Warning)
      .build()

    connection.value.onreconnecting(() => {
      isConnected.value = false
    })

    connection.value.onreconnected(() => {
      isConnected.value = true
    })

    connection.value.onclose(() => {
      isConnected.value = false
    })

    await connection.value.start()
    isConnected.value = true
  }

  const on = (method: string, callback: (...args: any[]) => void) => {
    connection.value?.on(method, callback)
  }

  const invoke = async (method: string, ...args: any[]) => {
    return connection.value?.invoke(method, ...args)
  }

  onMounted(start)
  onUnmounted(() => connection.value?.stop())

  return { connection, isConnected, on, invoke }
}
<!-- components/ChatRoom.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useSignalR } from '@/composables/useSignalR'

const { on, invoke, isConnected } = useSignalR('/hubs/chat')
const messages = ref<ChatMessage[]>([])
const newMessage = ref('')
const currentRoom = ref('general')

onMounted(() => {
  on('ReceiveMessage', (msg: ChatMessage) => {
    messages.value.push(msg)
  })

  on('UserJoined', (userName: string) => {
    messages.value.push({
      sender: 'System',
      content: `${userName} joined the room`,
      timestamp: new Date()
    })
  })

  invoke('JoinRoom', currentRoom.value)
})

const sendMessage = async () => {
  if (!newMessage.value.trim()) return
  await invoke('SendToRoom', currentRoom.value, newMessage.value)
  newMessage.value = ''
}
</script>

5. Scale-out — from a single server to millions of connections

By default, SignalR keeps connection state in a single process's memory. When you deploy multiple server instances (horizontal scaling), client A connecting to server 1 can't receive messages from client B connected to server 2. That's the scale-out problem to solve.

5.1. Redis Backplane — self-managed scale-out

The Redis Backplane uses Redis Pub/Sub to synchronize messages between server instances. When server 1 needs to broadcast a message, it publishes to a Redis channel; all other servers subscribed to that channel forward the message to their clients.

graph TB
    C1["Client A"] --> S1["Server 1"]
    C2["Client B"] --> S1
    C3["Client C"] --> S2["Server 2"]
    C4["Client D"] --> S2
    C5["Client E"] --> S3["Server 3"]
    S1 <-->|"Pub/Sub"| REDIS["Redis
Backplane"] S2 <-->|"Pub/Sub"| REDIS S3 <-->|"Pub/Sub"| REDIS LB["Load Balancer
(Sticky Sessions)"] --> S1 LB --> S2 LB --> S3 style REDIS fill:#e94560,stroke:#fff,color:#fff style LB fill:#2c3e50,stroke:#e94560,color:#fff style S1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style S2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style S3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style C1 fill:#2c3e50,stroke:#e94560,color:#fff style C2 fill:#2c3e50,stroke:#e94560,color:#fff style C3 fill:#2c3e50,stroke:#e94560,color:#fff style C4 fill:#2c3e50,stroke:#e94560,color:#fff style C5 fill:#2c3e50,stroke:#e94560,color:#fff

Figure 4: Redis Backplane — synchronizes messages between multiple server instances via Pub/Sub

// Program.cs — Redis Backplane setup
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR()
    .AddStackExchangeRedis(options =>
    {
        options.Configuration = builder.Configuration
            .GetConnectionString("Redis")!;
        options.Configuration.ChannelPrefix =
            RedisChannel.Literal("ChatApp");
    });

var app = builder.Build();
app.MapHub<ChatHub>("/hubs/chat");
app.Run();

The Redis Backplane still needs sticky sessions!

SignalR uses a two-step negotiate flow: (1) the client calls /negotiate to fetch a connectionId, (2) the client opens the transport (WebSocket/SSE) with that connectionId. Both requests must go to the same server — otherwise the second server can't find the connection context. Configuring your load balancer with sticky sessions (by cookie or IP) is mandatory when using the Redis Backplane.

5.2. Azure SignalR Service — managed scale-out

If you deploy on Azure and want to avoid managing a Redis backplane and sticky sessions, Azure SignalR Service is the managed option. Azure manages every WebSocket connection — your app server only runs business logic and doesn't hold connection state.

graph LR
    C1["Clients"] -->|"WebSocket"| AZURE["Azure SignalR
Service"] AZURE -->|"Server Connection"| S1["App Server 1"] AZURE -->|"Server Connection"| S2["App Server 2"] S1 --> DB["Database"] S2 --> DB style AZURE fill:#e94560,stroke:#fff,color:#fff style S1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style S2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style C1 fill:#2c3e50,stroke:#e94560,color:#fff style DB fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Figure 5: Azure SignalR Service — clients connect to Azure, app servers only handle logic

// Program.cs — Azure SignalR Service
builder.Services.AddSignalR()
    .AddAzureSignalR(options =>
    {
        options.ConnectionString = builder.Configuration
            .GetConnectionString("AzureSignalR")!;
        options.ServerStickyMode =
            ServerStickyMode.Required; // Ensures consistency
    });
CriterionRedis BackplaneAzure SignalR Service
Sticky sessionsRequiredNot required
Connection managementApp server holds itAzure manages it
Scale limitDepends on Redis + serversMillions of connections
LatencyLow (same datacenter)Extra hop to Azure
CostCost of Redis serversPer-unit pricing (~$50/unit)
Message loss if Redis goes downYes (no buffer)Azure guarantees delivery
Pick whenSelf-hosted, latency-criticalAzure-hosted, need scale >10K connections

6. SignalR vs SSE vs raw WebSocket — pick the right tool

Not every real-time feature needs SignalR. Here's a decision framework based on the actual requirements:

RequirementSolutionWhy
One-way server push (dashboards, notifications)SSE + Results.ServerSentEventsNo client library, no backplane, HTTP/2 multiplexing
Chat, collaborative editing, gamesSignalRTwo-way communication, Groups, Users, auto-reconnect, type safety
Custom binary protocol, video/audio streamingRaw WebSocketFull control over framing, no SignalR protocol overhead
IoT sensor data (thousands of devices)MQTT or raw WebSocketSignalR's overhead doesn't fit IoT scale
Need to scale >100K connections on AzureAzure SignalR ServiceManaged, no sticky sessions, auto-scale

General principle for 2026

Most "real-time" features in enterprise apps only need unidirectional push — dashboard metrics, order status, notification feeds. In those cases, SSE with Results.ServerSentEvents on .NET 10 is the simplest pick with the least infrastructure overhead. Reach for SignalR when you genuinely need bidirectional communication or advanced features like Groups, Presence, and Strongly-Typed Hubs.

7. Authentication and authorization for SignalR

SignalR Hubs can use the same authentication/authorization pipeline as ASP.NET Core. With JWT Bearer tokens, clients pass the token via query string (since browsers can't set custom headers on a WebSocket connection).

// Program.cs — Authentication for SignalR Hubs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                // SignalR passes the token via query string
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;

                if (!string.IsNullOrEmpty(accessToken)
                    && path.StartsWithSegments("/hubs"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });

// Hub with authorization
[Authorize]
public class SecureChatHub : Hub<IChatClient>
{
    [Authorize(Roles = "Admin")]
    public async Task DeleteMessage(string messageId)
    {
        await Clients.All.MessageDeleted(messageId);
    }

    public async Task SendMessage(string message)
    {
        var userId = Context.UserIdentifier!;
        var userName = Context.User!.FindFirst("name")!.Value;
        await Clients.Others.ReceiveMessage(
            new ChatMessage(userName, message, DateTime.UtcNow));
    }
}

8. Best practices and anti-patterns

8.1. Managing the connection lifetime

// Client-side: automatic reconnect with exponential backoff
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/chat")
    .withAutomaticReconnect({
        nextRetryDelayInMilliseconds: (retryContext) => {
            // Exponential backoff: 0s, 2s, 4s, 8s, 16s, max 30s
            const delay = Math.min(
                Math.pow(2, retryContext.previousRetryCount) * 1000,
                30000
            );
            return delay;
        }
    })
    .build();

// Handle reconnection events
connection.onreconnecting((error) => {
    showToast("Reconnecting...", "warning");
});

connection.onreconnected((connectionId) => {
    showToast("Reconnected!", "success");
    // Re-join rooms after reconnecting
    connection.invoke("JoinRoom", currentRoom);
});

connection.onclose((error) => {
    showToast("Connection lost. Please reload the page.", "error");
});

8.2. Anti-patterns to avoid

Anti-patternProblemFix
Sending large payloads through the HubBlocks the connection, increases memory pressureUpload files via HTTP; only send notifications via SignalR
Blocking calls inside a Hub methodBlocks the I/O thread, reduces throughputAlways use async/await — never Task.Result
Storing state in a static field of the HubHub instances are created per invocationUse a DI service (Singleton/Scoped) or IHubContext
Not handling reconnectionUsers don't realize they lost the connectionwithAutomaticReconnect() + UI feedback
Broadcasting too oftenFloods clients, bloats bandwidthThrottle/debounce on the server; send only deltas

Hub instances are transient!

Every time a client invokes a Hub method, ASP.NET Core creates a new Hub instance. Don't store state in Hub class properties or fields — it'll be lost after each invocation. Instead, use IHubContext injected into a Singleton service, or use external storage (Redis, database) for shared state.

8.3. Performance tuning

// Program.cs — Tuning SignalR for production
builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = false; // Production: disable detailed errors
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
    options.HandshakeTimeout = TimeSpan.FromSeconds(5);
    options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB max message
    options.StreamBufferCapacity = 10;
})
.AddMessagePackProtocol(); // Binary protocol instead of JSON — ~30% smaller

The MessagePack protocol

By default, SignalR uses a JSON protocol. Switching to MessagePack (binary serialization) shrinks messages by ~30% and accelerates serialization/deserialization. Especially effective for numeric data (dashboard metrics, game state). Install with: dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack.

9. Conclusion

SignalR on .NET 10 remains the strongest production-ready real-time solution in the .NET ecosystem. With Strongly-Typed Hubs, automatic transport negotiation, and scale-out via Redis Backplane or Azure SignalR Service, it fully addresses real-time communication — from simple chat to million-connection dashboards.

That said, .NET 10 also brings Results.ServerSentEvents — a considerably lighter option when all you need is one-way server push. The rule of thumb: SSE for unidirectional push (dashboards, notification feeds), SignalR for bidirectional communication (chat, collaboration), and raw WebSocket for custom binary protocols. Don't default to SignalR for every real-time feature — picking the right tool cuts complexity and infrastructure cost significantly.

References