Learnixo
Back to blog
Backend Systemsadvanced

SignalR Deep Dive — Real-Time Patterns in .NET 9

Production SignalR in .NET 9: hub design, groups and connection management, backplane scaling with Redis, typed clients, reconnection strategies, authentication, horizontal scaling, and performance tuning.

Asma Hafeez KhanMay 26, 202611 min read
.NETC#SignalRWebSocketReal-TimeRedisScalingHubs
Share:𝕏

SignalR Deep Dive — Real-Time Patterns in .NET 9

SignalR abstracts WebSockets, Server-Sent Events, and long polling into a single API. For most .NET teams it is the right tool for real-time features — but the default configuration works for demos, not production. This article covers the decisions that determine whether SignalR holds up under load.

What you'll build:

  • Typed hub with group management
  • Redis backplane for horizontal scaling
  • Reconnection with message replay
  • Connection lifecycle events
  • Authentication and per-connection authorization
  • Performance tuning for high-concurrency scenarios

When SignalR, When Not

SignalR is the right choice when:

  • You need server → client push (notifications, live dashboards, collaborative editing)
  • You need bidirectional communication (chat, multiplayer, live support)
  • Your clients are browsers or .NET apps

Consider alternatives when:

  • You only need client → server (use HTTP)
  • You need very high fan-out to millions of clients (use a dedicated push service: Azure Web PubSub, Pusher)
  • You need message ordering guarantees across restarts (use Kafka + SSE)

1. Typed Hubs

An untyped hub uses string method names at runtime. A typed hub generates compile-time-safe client proxy interfaces — the compiler catches method name typos and signature mismatches.

Define the client interface

C#
// Hubs/INotificationClient.cs
public interface INotificationClient
{
    Task ReceiveNotification(NotificationDto notification);
    Task ReceiveSystemAlert(SystemAlertDto alert);
    Task UserJoined(string userId, string displayName);
    Task UserLeft(string userId);
    Task DocumentUpdated(string documentId, DocumentPatchDto patch);
    Task ConnectionAcknowledged(string connectionId, DateTimeOffset serverTime);
}

public record NotificationDto(string Id, string Title, string Body, string Type, DateTimeOffset CreatedAt);
public record SystemAlertDto(string Level, string Message);
public record DocumentPatchDto(int Version, string PatchJson, string AuthorId);

Implement the hub

C#
// Hubs/NotificationHub.cs
[Authorize]
public class NotificationHub : Hub<INotificationClient>
{
    private readonly IConnectionTracker _tracker;
    private readonly IMessageReplayStore _replayStore;
    private readonly ILogger<NotificationHub> _logger;

    public NotificationHub(
        IConnectionTracker tracker,
        IMessageReplayStore replayStore,
        ILogger<NotificationHub> logger)
    {
        _tracker = tracker;
        _replayStore = replayStore;
        _logger = logger;
    }

    // Called when a client connects
    public override async Task OnConnectedAsync()
    {
        var userId = Context.UserIdentifier!;
        var tenantId = Context.User!.FindFirst("tenant_id")!.Value;

        // Track connection
        await _tracker.RegisterAsync(Context.ConnectionId, userId, tenantId);

        // Join tenant group (all connections for this tenant)
        await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");

        // Join user group (all connections for this user — multi-tab)
        await Groups.AddToGroupAsync(Context.ConnectionId, $"user:{userId}");

        // Acknowledge connection
        await Clients.Caller.ConnectionAcknowledged(Context.ConnectionId, DateTimeOffset.UtcNow);

        // Replay missed messages since last disconnect
        var lastSeen = await _tracker.GetLastSeenAsync(userId);
        if (lastSeen.HasValue)
        {
            var missed = await _replayStore.GetSinceAsync(userId, lastSeen.Value);
            foreach (var msg in missed)
                await Clients.Caller.ReceiveNotification(msg);
        }

        await base.OnConnectedAsync();
    }

    // Called when a client disconnects
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var userId = Context.UserIdentifier!;

        await _tracker.RecordLastSeenAsync(userId, DateTimeOffset.UtcNow);
        await _tracker.UnregisterAsync(Context.ConnectionId);

        if (exception is not null)
            _logger.LogWarning(exception, "Connection {ConnectionId} disconnected with error", Context.ConnectionId);

        await base.OnDisconnectedAsync(exception);
    }

    // Client-callable hub method
    public async Task JoinDocument(string documentId)
    {
        var tenantId = Context.User!.FindFirst("tenant_id")!.Value;
        // Verify the user has access to this document before joining
        await Groups.AddToGroupAsync(Context.ConnectionId, $"doc:{tenantId}:{documentId}");
    }

    public async Task LeaveDocument(string documentId)
    {
        var tenantId = Context.User!.FindFirst("tenant_id")!.Value;
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"doc:{tenantId}:{documentId}");
    }

    public async Task SendDocumentPatch(string documentId, string patchJson)
    {
        var userId = Context.UserIdentifier!;
        var tenantId = Context.User!.FindFirst("tenant_id")!.Value;

        var patch = new DocumentPatchDto(
            Version: await _replayStore.NextVersionAsync(documentId),
            PatchJson: patchJson,
            AuthorId: userId
        );

        // Broadcast to all users viewing this document except the sender
        await Clients
            .GroupExcept($"doc:{tenantId}:{documentId}", Context.ConnectionId)
            .DocumentUpdated(documentId, patch);
    }
}

Register the hub

C#
// Program.cs
builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.MaximumReceiveMessageSize = 32 * 1024; // 32 KB max message
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(60);
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.HandshakeTimeout = TimeSpan.FromSeconds(15);
});

// ...

app.MapHub<NotificationHub>("/hubs/notifications");

2. Server-Side Push (Outside the Hub)

Most real-time events originate outside the hub — a background job completes, a database row changes, a message arrives on a queue. Inject IHubContext<THub, TClient> to push from anywhere.

C#
// Services/NotificationDispatcher.cs
public class NotificationDispatcher
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hub;
    private readonly IMessageReplayStore _replayStore;

    public NotificationDispatcher(
        IHubContext<NotificationHub, INotificationClient> hub,
        IMessageReplayStore replayStore)
    {
        _hub = hub;
        _replayStore = replayStore;
    }

    // Send to a specific user (all their connections/tabs)
    public async Task SendToUserAsync(string userId, NotificationDto notification, CancellationToken ct = default)
    {
        await _replayStore.StoreAsync(userId, notification);
        await _hub.Clients.Group($"user:{userId}").ReceiveNotification(notification);
    }

    // Broadcast to all users in a tenant
    public async Task SendToTenantAsync(string tenantId, SystemAlertDto alert, CancellationToken ct = default)
    {
        await _hub.Clients.Group($"tenant:{tenantId}").ReceiveSystemAlert(alert);
    }

    // Send to users viewing a specific document
    public async Task SendDocumentUpdateAsync(string tenantId, string documentId, DocumentPatchDto patch)
    {
        await _hub.Clients
            .Group($"doc:{tenantId}:{documentId}")
            .DocumentUpdated(documentId, patch);
    }
}
C#
// Background worker that processes a queue and pushes updates
public class OrderStatusWorker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        await foreach (var statusChange in _orderQueue.ReadAllAsync(ct))
        {
            using var scope = _scopeFactory.CreateScope();
            var dispatcher = scope.ServiceProvider.GetRequiredService<NotificationDispatcher>();

            await dispatcher.SendToUserAsync(statusChange.CustomerId, new NotificationDto(
                Id: Guid.NewGuid().ToString(),
                Title: "Order Update",
                Body: $"Your order #{statusChange.OrderId} is now {statusChange.Status}",
                Type: "order_status",
                CreatedAt: DateTimeOffset.UtcNow
            ), ct);
        }
    }
}

3. Redis Backplane for Horizontal Scaling

A single SignalR server instance stores group memberships and connections in memory. When you run two instances behind a load balancer, a client on instance A cannot receive messages sent from instance B. The Redis backplane synchronises messages across all instances.

Bash
dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
C#
// Program.cs
builder.Services
    .AddSignalR()
    .AddStackExchangeRedis(
        builder.Configuration.GetConnectionString("Redis")!,
        options =>
        {
            options.Configuration.ChannelPrefix = RedisChannel.Literal("myapp");
            options.Configuration.ConnectRetry = 5;
            options.Configuration.ReconnectRetryPolicy = new LinearRetry(1000);
        });

What the backplane does: When you call Clients.Group("tenant:abc").ReceiveNotification(...), SignalR publishes that message to a Redis Pub/Sub channel. Every other instance subscribed to that channel receives it and forwards it to its locally-connected clients.

What the backplane does NOT do: It does not store group membership. Groups are per-instance in-memory. If an instance restarts, its clients reconnect and must re-join their groups via OnConnectedAsync. This is why OnConnectedAsync must always add the connection to all relevant groups — it runs on every connection, including reconnects.

Redis connection resilience

C#
// Use a shared IConnectionMultiplexer for health checks + backplane
builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var config = ConfigurationOptions.Parse(
        builder.Configuration.GetConnectionString("Redis")!);
    config.AbortOnConnectFail = false;
    config.ConnectRetry = 5;
    config.ReconnectRetryPolicy = new ExponentialRetry(1000, 30_000);
    return ConnectionMultiplexer.Connect(config);
});

4. Authentication and Authorization

JWT Bearer authentication for SignalR

Browser WebSocket connections cannot send custom headers. SignalR passes the access token as a query string parameter (?access_token=...). Configure the JWT handler to read from query string for hub paths:

C#
builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience = builder.Configuration["Auth:Audience"];

        // SignalR: read token from query string for WebSocket connections
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                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;
            }
        };
    });

Map user identity to connection

SignalR uses IUserIdProvider to determine which user identifier to assign to each connection. The default uses ClaimTypes.NameIdentifier. Override it to use a different claim:

C#
// Hubs/TenantUserIdProvider.cs
public class TenantUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext connection)
    {
        // Use the "sub" claim (standard OIDC subject identifier)
        return connection.User?.FindFirst("sub")?.Value;
    }
}

// Program.cs
builder.Services.AddSingleton<IUserIdProvider, TenantUserIdProvider>();

With this configured, Context.UserIdentifier in the hub returns the sub claim value, and Clients.User(userId) targets all connections for that user.

Per-method authorization

C#
[Authorize]
public class NotificationHub : Hub<INotificationClient>
{
    // Requires authentication (from class-level attribute)
    public async Task JoinDocument(string documentId) { ... }

    // Requires a specific role
    [Authorize(Roles = "Admin")]
    public async Task BroadcastSystemAlert(string message)
    {
        await Clients.All.ReceiveSystemAlert(new SystemAlertDto("warning", message));
    }

    // Custom policy
    [Authorize(Policy = "CanEditDocuments")]
    public async Task SendDocumentPatch(string documentId, string patchJson) { ... }
}

5. Client Reconnection and Message Replay

The default SignalR JavaScript client retries with a fixed delay. For production, use a custom retry policy and replay missed messages after reconnect.

TypeScript client with reconnection

TYPESCRIPT
// lib/signalr-client.ts
import * as signalR from "@microsoft/signalr";

export function createHubConnection(token: string): signalR.HubConnection {
  return new signalR.HubConnectionBuilder()
    .withUrl("/hubs/notifications", {
      accessTokenFactory: () => token,
    })
    .withAutomaticReconnect({
      // Custom delays: 0, 2, 5, 10, 30 seconds, then 30s intervals
      nextRetryDelayInMilliseconds: (retryContext) => {
        const delays = [0, 2000, 5000, 10000, 30000];
        return delays[retryContext.previousRetryCount] ?? 30000;
      },
    })
    .configureLogging(
      process.env.NODE_ENV === "development"
        ? signalR.LogLevel.Information
        : signalR.LogLevel.Warning
    )
    .build();
}
TYPESCRIPT
// hooks/useNotifications.ts (React)
export function useNotifications() {
  const { token } = useAuth();
  const connectionRef = useRef<signalR.HubConnection | null>(null);

  useEffect(() => {
    const connection = createHubConnection(token);

    connection.on("ReceiveNotification", (notification: NotificationDto) => {
      notificationStore.add(notification);
    });

    connection.onreconnecting((error) => {
      console.warn("SignalR reconnecting:", error?.message);
      setConnectionState("reconnecting");
    });

    connection.onreconnected((connectionId) => {
      console.info("SignalR reconnected:", connectionId);
      setConnectionState("connected");
      // Re-join document groups after reconnect
      if (currentDocumentId) {
        connection.invoke("JoinDocument", currentDocumentId);
      }
    });

    connection.onclose((error) => {
      console.error("SignalR connection closed:", error?.message);
      setConnectionState("disconnected");
    });

    connection.start()
      .then(() => setConnectionState("connected"))
      .catch(console.error);

    connectionRef.current = connection;

    return () => {
      connection.stop();
    };
  }, [token]);

  return connectionRef;
}

Server-side message replay store

C#
// Services/MessageReplayStore.cs
public class MessageReplayStore : IMessageReplayStore
{
    private readonly IDatabase _redis;
    private static readonly TimeSpan MessageTtl = TimeSpan.FromHours(24);

    public MessageReplayStore(IConnectionMultiplexer redis)
    {
        _redis = redis.GetDatabase();
    }

    public async Task StoreAsync(string userId, NotificationDto notification)
    {
        var key = $"replay:{userId}";
        var json = JsonSerializer.Serialize(notification);

        // Store with score = epoch seconds for time-based range queries
        await _redis.SortedSetAddAsync(key,
            json,
            DateTimeOffset.UtcNow.ToUnixTimeSeconds());

        // Expire the key to cap storage
        await _redis.KeyExpireAsync(key, MessageTtl);
    }

    public async Task<List<NotificationDto>> GetSinceAsync(string userId, DateTimeOffset since)
    {
        var key = $"replay:{userId}";
        var entries = await _redis.SortedSetRangeByScoreAsync(
            key,
            min: since.ToUnixTimeSeconds(),
            max: double.PositiveInfinity);

        return entries
            .Select(e => JsonSerializer.Deserialize<NotificationDto>(e!)!)
            .ToList();
    }

    public async Task<int> NextVersionAsync(string documentId)
    {
        return (int)await _redis.StringIncrementAsync($"doc-version:{documentId}");
    }
}

6. Connection Tracking

Knowing which users are online is a common requirement. Track it in Redis so all instances share state:

C#
// Services/ConnectionTracker.cs
public class ConnectionTracker : IConnectionTracker
{
    private readonly IDatabase _redis;
    private static readonly TimeSpan OnlineTtl = TimeSpan.FromMinutes(2);

    public ConnectionTracker(IConnectionMultiplexer redis)
    {
        _redis = redis.GetDatabase();
    }

    public async Task RegisterAsync(string connectionId, string userId, string tenantId)
    {
        var batch = _redis.CreateBatch();

        // Map connection → user
        _ = batch.StringSetAsync($"conn:{connectionId}", userId, OnlineTtl);

        // Add to tenant's online set
        _ = batch.SetAddAsync($"online:{tenantId}", userId);

        // Heartbeat key — expires if client goes silent
        _ = batch.StringSetAsync($"heartbeat:{userId}", connectionId, OnlineTtl);

        batch.Execute();
        await Task.CompletedTask;
    }

    public async Task UnregisterAsync(string connectionId)
    {
        var userId = await _redis.StringGetAsync($"conn:{connectionId}");
        if (!userId.IsNull)
        {
            await _redis.KeyDeleteAsync($"conn:{connectionId}");
            await _redis.KeyDeleteAsync($"heartbeat:{userId!}");
        }
    }

    public async Task RecordLastSeenAsync(string userId, DateTimeOffset time)
    {
        await _redis.StringSetAsync(
            $"last-seen:{userId}",
            time.ToUnixTimeSeconds().ToString(),
            TimeSpan.FromDays(30));
    }

    public async Task<DateTimeOffset?> GetLastSeenAsync(string userId)
    {
        var val = await _redis.StringGetAsync($"last-seen:{userId}");
        if (val.IsNull) return null;
        return DateTimeOffset.FromUnixTimeSeconds(long.Parse(val!));
    }

    public async Task<bool> IsOnlineAsync(string userId)
    {
        return await _redis.KeyExistsAsync($"heartbeat:{userId}");
    }

    public async Task<HashSet<string>> GetOnlineUsersAsync(string tenantId)
    {
        var members = await _redis.SetMembersAsync($"online:{tenantId}");
        var online = new HashSet<string>();

        foreach (var m in members)
        {
            if (await IsOnlineAsync(m!))
                online.Add(m!);
            else
                await _redis.SetRemoveAsync($"online:{tenantId}", m); // cleanup
        }

        return online;
    }
}

7. Performance Tuning

MessagePack over JSON

SignalR serialises messages as JSON by default. MessagePack is binary, roughly 2-3x smaller, and faster to serialise/deserialise — important at high message rates.

Bash
dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack
C#
builder.Services
    .AddSignalR()
    .AddMessagePackProtocol();
TYPESCRIPT
// Client must also use MessagePack
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";

new signalR.HubConnectionBuilder()
  .withUrl("/hubs/notifications")
  .withHubProtocol(new MessagePackHubProtocol())
  .build();

Streaming for large or continuous data

Instead of sending one large payload, stream it:

C#
// Hub method returning a stream
public async IAsyncEnumerable<AuditEventDto> StreamAuditLog(
    string tenantId,
    DateTimeOffset from,
    [EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var evt in _auditLog.GetEventsAsync(tenantId, from, ct))
    {
        yield return evt;
    }
}
TYPESCRIPT
// Client subscribes to the stream
const stream = connection.stream<AuditEventDto>("StreamAuditLog", tenantId, fromDate);
stream.subscribe({
  next: (event) => auditLog.push(event),
  error: (err) => console.error(err),
  complete: () => console.log("Stream complete"),
});

Limit concurrent connections per user

Prevent a single user from opening hundreds of tabs and consuming all server resources:

C#
public override async Task OnConnectedAsync()
{
    var userId = Context.UserIdentifier!;
    var connections = await _tracker.GetConnectionCountAsync(userId);

    if (connections > 10)
    {
        Context.Abort(); // close this new connection
        return;
    }

    await base.OnConnectedAsync();
}

8. Health Check for SignalR

Verify that the Redis backplane is reachable and SignalR can route messages:

C#
// HealthChecks/SignalRBackplaneHealthCheck.cs
public class SignalRBackplaneHealthCheck : IHealthCheck
{
    private readonly IConnectionMultiplexer _redis;

    public SignalRBackplaneHealthCheck(IConnectionMultiplexer redis)
    {
        _redis = redis;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken ct = default)
    {
        try
        {
            await _redis.GetDatabase().PingAsync();
            return HealthCheckResult.Healthy("SignalR Redis backplane is reachable");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("SignalR backplane unreachable", ex);
        }
    }
}

// Program.cs
builder.Services.AddHealthChecks()
    .AddCheck<SignalRBackplaneHealthCheck>("signalr-backplane", tags: ["ready"]);

Common Mistakes

Storing state in hub instance variables. Hub instances are created per-method call, not per connection. Never store connection-specific data in hub fields — use Context.Items or an injected scoped service.

Calling hub methods from a static context. You cannot access Context or Clients from a static method. Push from outside hubs using IHubContext.

Not re-joining groups after reconnect. Groups are in-memory per instance. OnConnectedAsync fires on every connect, including automatic reconnects — always add the connection to all relevant groups there.

Forgetting the backplane when scaling. A single-instance load test works fine; a two-instance load test silently loses half the messages. Test with multiple instances from day one if you plan to scale.

Using Clients.All for tenant-scoped messages. Broadcasting to all connected clients leaks data across tenants. Always scope broadcasts to a tenant group: Clients.Group($"tenant:{tenantId}").

WebSocket & Real-Time Knowledge Check

5 questions · Test what you just learned · Instant explanations

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.