.NET Core

Real-time Apps with SignalR in .NET

12 min readMarch 9, 2026
SignalR .NETReal-time .NETWebSocket .NETSignalR HubSignalR RedisSignalR scalingReal-time dashboardSignalR authentication

Building real-time features -- live dashboards, chat systems, collaborative editors, notification feeds -- requires a fundamentally different communication model than traditional request-response. SignalR abstracts the complexity of persistent connections, transport negotiation, and reconnection logic into a clean programming model. In real-time dashboards I've built for monitoring platforms, SignalR consistently proved to be the fastest path from idea to production-ready bidirectional communication.

Why SignalR Over Raw WebSockets

SignalR is not just a WebSocket wrapper. It handles transport fallback (WebSockets, Server-Sent Events, Long Polling), automatic reconnection, connection lifecycle management, and message serialization. Writing all of this from scratch with raw WebSockets is possible but rarely worth the effort for business applications.

The key advantages:

  • Automatic transport negotiation based on client and server capabilities
  • Built-in connection grouping and user tracking
  • Strongly-typed hub contracts for compile-time safety
  • Native integration with ASP.NET Core authentication and authorization
  • Scaling support via Redis, Azure SignalR Service, or custom backplanes

Setting Up a SignalR Hub

The Hub is the server-side entry point for all client communication. Each public method on the hub can be invoked by connected clients.

Basic Hub

csharp
public class NotificationHub : Hub
{
    private readonly ILogger<NotificationHub> _logger;

    public NotificationHub(ILogger<NotificationHub> logger)
    {
        _logger = logger;
    }

    public async Task SendNotification(string user, string message)
    {
        _logger.LogInformation(
            "Notification from {ConnectionId} to {User}",
            Context.ConnectionId, user);

        await Clients.User(user).SendAsync("ReceiveNotification", message);
    }

    public async Task BroadcastMessage(string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", Context.User?.Identity?.Name, message);
    }

    public override async Task OnConnectedAsync()
    {
        _logger.LogInformation("Client connected: {ConnectionId}", Context.ConnectionId);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        _logger.LogInformation(
            "Client disconnected: {ConnectionId}, Reason: {Reason}",
            Context.ConnectionId, exception?.Message ?? "clean disconnect");
        await base.OnDisconnectedAsync(exception);
    }
}

Registration in Program.cs

csharp
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
    options.MaximumReceiveMessageSize = 64 * 1024; // 64 KB
});

var app = builder.Build();

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

Strongly-Typed Hubs

String-based method names in SendAsync("ReceiveMessage", ...) are fragile. A typo compiles fine but fails silently at runtime. Strongly-typed hubs eliminate this entirely.

Define the Client Interface

csharp
public interface INotificationClient
{
    Task ReceiveNotification(string message);
    Task ReceiveMessage(string user, string message);
    Task UserJoinedGroup(string user, string group);
    Task UserLeftGroup(string user, string group);
    Task ReceiveDashboardUpdate(DashboardData data);
}

Implement the Strongly-Typed Hub

csharp
public class NotificationHub : Hub<INotificationClient>
{
    public async Task SendNotification(string user, string message)
    {
        // Compile-time checked -- no more magic strings
        await Clients.User(user).ReceiveNotification(message);
    }

    public async Task BroadcastMessage(string message)
    {
        var sender = Context.User?.Identity?.Name ?? "Anonymous";
        await Clients.All.ReceiveMessage(sender, message);
    }

    public async Task SendDashboardUpdate(DashboardData data)
    {
        await Clients.Group("dashboard-viewers").ReceiveDashboardUpdate(data);
    }
}

In real-time dashboards I've built, strongly-typed hubs caught multiple bugs during compilation that would have been silent runtime failures. The small upfront cost of defining the interface pays for itself immediately.

Client-Server Communication Patterns

SignalR supports several communication patterns, each suited to different scenarios.

Server to Client

The most common pattern. The server pushes data to connected clients without them requesting it.

csharp
// From within a Hub method
await Clients.All.ReceiveMessage(user, message);          // All clients
await Clients.Caller.ReceiveNotification("Acknowledged");  // Only the caller
await Clients.Others.ReceiveMessage(user, message);        // Everyone except caller
await Clients.User(userId).ReceiveNotification(msg);       // Specific user
await Clients.Group("admins").ReceiveNotification(msg);    // Group members

Client to Server

Clients invoke hub methods directly. Here is a JavaScript client example:

javascript
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/notifications", {
        accessTokenFactory: () => getAccessToken()
    })
    .withAutomaticReconnect([0, 2000, 5000, 10000, 30000])
    .configureLogging(signalR.LogLevel.Information)
    .build();

// Receive messages from server
connection.on("ReceiveMessage", (user, message) => {
    console.log(`${user}: ${message}`);
    appendMessageToUI(user, message);
});

connection.on("ReceiveDashboardUpdate", (data) => {
    updateDashboard(data);
});

// Send messages to server
async function sendMessage(message) {
    await connection.invoke("BroadcastMessage", message);
}

// Start connection
await connection.start();

Invoking Hub Methods from Outside the Hub

In production systems, you often need to push messages from background services, API controllers, or event handlers -- not just from within a hub method.

csharp
public class OrderProcessingService : BackgroundService
{
    private readonly IHubContext<NotificationHub, INotificationClient> _hubContext;

    public OrderProcessingService(
        IHubContext<NotificationHub, INotificationClient> hubContext)
    {
        _hubContext = hubContext;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var updates = await GetPendingUpdates();

            foreach (var update in updates)
            {
                await _hubContext.Clients.User(update.UserId)
                    .ReceiveNotification($"Order {update.OrderId} status: {update.Status}");
            }

            await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
        }
    }
}

Groups and User Management

Groups are the primary mechanism for organizing which clients receive which messages. They are lightweight, dynamic, and require no pre-registration.

Group Operations

csharp
public class CollaborationHub : Hub<ICollaborationClient>
{
    private readonly IGroupTracker _groupTracker;

    public CollaborationHub(IGroupTracker groupTracker)
    {
        _groupTracker = groupTracker;
    }

    public async Task JoinProject(string projectId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"project-{projectId}");

        var userName = Context.User?.Identity?.Name ?? "Anonymous";
        await _groupTracker.AddUserToGroup(projectId, userName, Context.ConnectionId);

        await Clients.Group($"project-{projectId}")
            .UserJoinedGroup(userName, projectId);
    }

    public async Task LeaveProject(string projectId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"project-{projectId}");

        var userName = Context.User?.Identity?.Name ?? "Anonymous";
        await _groupTracker.RemoveUserFromGroup(projectId, Context.ConnectionId);

        await Clients.Group($"project-{projectId}")
            .UserLeftGroup(userName, projectId);
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        // Clean up all group memberships for this connection
        var groups = await _groupTracker.GetGroupsForConnection(Context.ConnectionId);
        foreach (var group in groups)
        {
            await _groupTracker.RemoveUserFromGroup(group, Context.ConnectionId);
            await Clients.Group($"project-{group}")
                .UserLeftGroup(Context.User?.Identity?.Name ?? "Anonymous", group);
        }

        await base.OnDisconnectedAsync(exception);
    }
}

A critical detail: SignalR groups have no persistence. When a server restarts, all group memberships are lost. If your application needs durable group membership, maintain it in your own data store and rejoin groups on reconnection.

Authentication with SignalR

SignalR integrates directly with ASP.NET Core authentication. The same JWT or cookie authentication that protects your API endpoints protects your hubs.

JWT Authentication for SignalR

WebSocket connections cannot send custom HTTP headers after the initial handshake. SignalR solves this by accepting the token as a query string parameter during the negotiation phase.

csharp
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };

        // SignalR sends the token as a query string parameter
        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;
            }
        };
    });

Securing Hub Methods

csharp
[Authorize]
public class SecureHub : Hub<INotificationClient>
{
    [Authorize(Roles = "Admin")]
    public async Task AdminBroadcast(string message)
    {
        await Clients.All.ReceiveNotification(message);
    }

    [Authorize(Policy = "PremiumUser")]
    public async Task PremiumFeature(string data)
    {
        await Clients.Caller.ReceiveNotification($"Premium result: {data}");
    }
}

Scaling SignalR with Redis Backplane

A single server handles all its connections in memory. When you scale to multiple servers behind a load balancer, a client connected to Server A cannot receive messages sent from Server B. The Redis backplane solves this by broadcasting messages across all servers.

Configuration

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

How It Works

  1. Client connects to Server A and joins the "dashboard" group
  2. A background service on Server B sends a message to the "dashboard" group
  3. Server B publishes the message to Redis
  4. Server A subscribes to the Redis channel, receives the message, and forwards it to the client

When to Use Azure SignalR Service Instead

For production workloads exceeding a few thousand concurrent connections, consider Azure SignalR Service. It offloads connection management entirely, eliminating the need for sticky sessions, Redis infrastructure, and connection-count capacity planning. You pay per unit, but the operational simplicity is often worth it.

csharp
builder.Services.AddSignalR()
    .AddAzureSignalR(builder.Configuration["Azure:SignalR:ConnectionString"]);

Error Handling and Reconnection Strategy

Real-time connections are inherently fragile. Networks drop, servers restart, clients go to sleep. A robust reconnection strategy is non-negotiable for production systems.

Server-Side Error Handling

csharp
public class ResilientHub : Hub<INotificationClient>
{
    private readonly ILogger<ResilientHub> _logger;

    public ResilientHub(ILogger<ResilientHub> logger)
    {
        _logger = logger;
    }

    public async Task ProcessData(DataRequest request)
    {
        try
        {
            var result = await ValidateAndProcess(request);
            await Clients.Caller.ReceiveNotification($"Success: {result}");
        }
        catch (ValidationException ex)
        {
            // Send structured errors back to the caller
            _logger.LogWarning(ex, "Validation failed for {ConnectionId}", Context.ConnectionId);
            throw new HubException($"Validation failed: {ex.Message}");
        }
        catch (Exception ex)
        {
            // Log the full exception but send a safe message to the client
            _logger.LogError(ex, "Unexpected error for {ConnectionId}", Context.ConnectionId);
            throw new HubException("An unexpected error occurred. Please try again.");
        }
    }
}

Throwing HubException sends the message to the client. Any other exception type results in a generic error message unless EnableDetailedErrors is on. Never enable detailed errors in production -- it leaks stack traces and internal state.

Client-Side Reconnection

javascript
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/hubs/notifications")
    .withAutomaticReconnect({
        nextRetryDelayInMilliseconds: (retryContext) => {
            if (retryContext.elapsedMilliseconds < 60000) {
                return Math.random() * 10000; // Random delay up to 10 seconds
            }
            return null; // Stop retrying after 60 seconds
        }
    })
    .build();

connection.onreconnecting((error) => {
    showConnectionStatus("Reconnecting...");
    console.warn("Connection lost, attempting reconnect:", error);
});

connection.onreconnected((connectionId) => {
    showConnectionStatus("Connected");
    console.log("Reconnected with ID:", connectionId);
    // Rejoin groups after reconnection
    rejoinGroups();
});

connection.onclose((error) => {
    showConnectionStatus("Disconnected");
    console.error("Connection closed:", error);
    // Manual reconnect with exponential backoff
    setTimeout(() => startConnection(), 5000);
});

async function startConnection() {
    try {
        await connection.start();
        showConnectionStatus("Connected");
    } catch (err) {
        console.error("Connection failed:", err);
        setTimeout(() => startConnection(), 5000);
    }
}

The critical detail many teams miss: after reconnection, the client gets a new ConnectionId. Any group memberships from the previous connection are gone. You must rejoin groups explicitly. In real-time dashboards I've built, I maintain a client-side list of active subscriptions and replay them on every reconnect.

SignalR vs WebSockets vs Server-Sent Events

Each technology serves different use cases. Choosing the wrong one leads to unnecessary complexity or missing capabilities.

| Aspect | SignalR | Raw WebSockets | Server-Sent Events (SSE) |

|---|---|---|---|

| Direction | Bidirectional | Bidirectional | Server to Client only |

| Transport | Auto-negotiated | WebSocket only | HTTP streaming |

| Reconnection | Built-in | Manual implementation | Built-in (EventSource) |

| Browser Support | Universal (with fallback) | Modern browsers | Modern browsers |

| Message Format | JSON/MessagePack | Raw bytes/text | Text only |

| Groups/Users | Built-in | Manual implementation | Manual implementation |

| Scaling | Redis/Azure backplane | Custom solution | Custom solution |

| Auth Integration | ASP.NET Core native | Manual | Standard HTTP |

| Complexity | Low | High | Low |

| Best For | .NET full-stack apps | Custom protocols, games | Simple notifications, feeds |

Choose SignalR when you are in the .NET ecosystem and need bidirectional communication with minimal boilerplate. Choose raw WebSockets when you need a custom binary protocol or are building something like a multiplayer game engine. Choose SSE when you only need server-to-client streaming and want the simplest possible implementation.

Common SignalR Mistakes

1. Storing State in Hub Instances

Hubs are transient. A new instance is created for every method invocation. Any state stored as a field on the hub class is lost between calls.

csharp
// WRONG -- this counter resets on every call
public class BadHub : Hub
{
    private int _messageCount = 0; // Always 0 on each invocation

    public async Task SendMessage(string msg)
    {
        _messageCount++; // Always 1
        await Clients.All.SendAsync("Count", _messageCount);
    }
}

// CORRECT -- use an injected singleton service
public class GoodHub : Hub
{
    private readonly IMessageCounter _counter;

    public GoodHub(IMessageCounter counter) => _counter = counter;

    public async Task SendMessage(string msg)
    {
        var count = _counter.Increment();
        await Clients.All.SendAsync("Count", count);
    }
}

2. Blocking the Hub with Long-Running Operations

Hub methods run on the SignalR connection's thread. Blocking it prevents the client from receiving other messages.

3. Forgetting Group Rejoin After Reconnection

As mentioned above, reconnection creates a new connection. Groups are not automatically restored. This is the number one source of "it works in development but not in production" bugs.

4. Not Configuring CORS Properly

SignalR uses both HTTP (for negotiation) and WebSockets. CORS must allow both. A common mistake is configuring CORS for API endpoints but forgetting that SignalR negotiation is also an HTTP request.

csharp
builder.Services.AddCors(options =>
{
    options.AddPolicy("SignalRPolicy", policy =>
    {
        policy.WithOrigins("https://app.example.com")
              .AllowAnyHeader()
              .AllowAnyMethod()
              .AllowCredentials(); // Required for SignalR
    });
});

5. Ignoring Message Size Limits

The default maximum message size is 32 KB. Sending large payloads without adjusting this limit causes silent disconnects. For dashboards with large data payloads, I increase the limit explicitly and implement pagination for the initial data load.

6. Not Using MessagePack for High-Throughput Scenarios

JSON is the default protocol, but for high-frequency updates (stock tickers, game state, IoT telemetry), MessagePack binary serialization reduces payload size by 30-50% and improves serialization speed significantly.

csharp
builder.Services.AddSignalR()
    .AddMessagePackProtocol();

Conclusion

SignalR turns real-time communication from a systems-level challenge into a straightforward application-level feature. The strongly-typed hub pattern, built-in group management, and seamless authentication integration make it the natural choice for .NET applications that need live updates. The critical decisions are around scaling strategy (Redis vs Azure SignalR Service), reconnection handling, and understanding that hubs are transient. Get those right, and SignalR disappears into the background -- which is exactly what good infrastructure should do.

I can help architect your real-time features and review your SignalR scaling strategy for production.

Related Articles

Have a Flutter Project?

I build high-performance Flutter applications for iOS, Android, and web.

Get in Touch