Real-time Apps with SignalR in .NET
# Real-time Apps with SignalR in .NET
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:
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
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(
class="code-string">"Notification from {ConnectionId} to {User}",
Context.ConnectionId, user);
await Clients.User(user).SendAsync(class="code-string">"ReceiveNotification", message);
}
public async Task BroadcastMessage(string message)
{
await Clients.All.SendAsync(class="code-string">"ReceiveMessage", Context.User?.Identity?.Name, message);
}
public override async Task OnConnectedAsync()
{
_logger.LogInformation(class="code-string">"Client connected: {ConnectionId}", Context.ConnectionId);
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation(
class="code-string">"Client disconnected: {ConnectionId}, Reason: {Reason}",
Context.ConnectionId, exception?.Message ?? class="code-string">"clean disconnect");
await base.OnDisconnectedAsync(exception);
}
}Registration in Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSignalR(options =>
{
options.EnableDetailedErrors = builder.Environment.IsDevelopment();
options.KeepAliveInterval = TimeSpan.FromSeconds(class="code-number">15);
options.ClientTimeoutInterval = TimeSpan.FromSeconds(class="code-number">30);
options.MaximumReceiveMessageSize = class="code-number">64 * class="code-number">1024; class=class="code-string">"code-comment">// class="code-number">64 KB
});
var app = builder.Build();
app.MapHub<NotificationHub>(class="code-string">"/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
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
public class NotificationHub : Hub<INotificationClient>
{
public async Task SendNotification(string user, string message)
{
class=class="code-string">"code-comment">// 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 ?? class="code-string">"Anonymous";
await Clients.All.ReceiveMessage(sender, message);
}
public async Task SendDashboardUpdate(DashboardData data)
{
await Clients.Group(class="code-string">"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.
class=class="code-string">"code-comment">// From within a Hub method
await Clients.All.ReceiveMessage(user, message); class=class="code-string">"code-comment">// All clients
await Clients.Caller.ReceiveNotification(class="code-string">"Acknowledged"); class=class="code-string">"code-comment">// Only the caller
await Clients.Others.ReceiveMessage(user, message); class=class="code-string">"code-comment">// Everyone except caller
await Clients.User(userId).ReceiveNotification(msg); class=class="code-string">"code-comment">// Specific user
await Clients.Group(class="code-string">"admins").ReceiveNotification(msg); class=class="code-string">"code-comment">// Group membersClient to Server
Clients invoke hub methods directly. Here is a JavaScript client example:
const connection = new signalR.HubConnectionBuilder()
.withUrl(class="code-string">"/hubs/notifications", {
accessTokenFactory: () => getAccessToken()
})
.withAutomaticReconnect([class="code-number">0, class="code-number">2000, class="code-number">5000, class="code-number">10000, class="code-number">30000])
.configureLogging(signalR.LogLevel.Information)
.build();
class=class="code-string">"code-comment">// Receive messages from server
connection.on(class="code-string">"ReceiveMessage", (user, message) => {
console.log(`${user}: ${message}`);
appendMessageToUI(user, message);
});
connection.on(class="code-string">"ReceiveDashboardUpdate", (data) => {
updateDashboard(data);
});
class=class="code-string">"code-comment">// Send messages to server
async function sendMessage(message) {
await connection.invoke(class="code-string">"BroadcastMessage", message);
}
class=class="code-string">"code-comment">// 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.
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($class="code-string">"Order {update.OrderId} status: {update.Status}");
}
await Task.Delay(TimeSpan.FromSeconds(class="code-number">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
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, $class="code-string">"project-{projectId}");
var userName = Context.User?.Identity?.Name ?? class="code-string">"Anonymous";
await _groupTracker.AddUserToGroup(projectId, userName, Context.ConnectionId);
await Clients.Group($class="code-string">"project-{projectId}")
.UserJoinedGroup(userName, projectId);
}
public async Task LeaveProject(string projectId)
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $class="code-string">"project-{projectId}");
var userName = Context.User?.Identity?.Name ?? class="code-string">"Anonymous";
await _groupTracker.RemoveUserFromGroup(projectId, Context.ConnectionId);
await Clients.Group($class="code-string">"project-{projectId}")
.UserLeftGroup(userName, projectId);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
class=class="code-string">"code-comment">// 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($class="code-string">"project-{group}")
.UserLeftGroup(Context.User?.Identity?.Name ?? class="code-string">"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.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration[class="code-string">"Jwt:Issuer"],
ValidAudience = builder.Configuration[class="code-string">"Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration[class="code-string">"Jwt:Key"]!))
};
class=class="code-string">"code-comment">// SignalR sends the token as a query string parameter
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query[class="code-string">"access_token"];
var path = context.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(accessToken) &&
path.StartsWithSegments(class="code-string">"/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});Securing Hub Methods
[Authorize]
public class SecureHub : Hub<INotificationClient>
{
[Authorize(Roles = class="code-string">"Admin")]
public async Task AdminBroadcast(string message)
{
await Clients.All.ReceiveNotification(message);
}
[Authorize(Policy = class="code-string">"PremiumUser")]
public async Task PremiumFeature(string data)
{
await Clients.Caller.ReceiveNotification($class="code-string">"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
builder.Services.AddSignalR()
.AddStackExchangeRedis(builder.Configuration.GetConnectionString(class="code-string">"Redis")!, options =>
{
options.Configuration.ChannelPrefix = RedisChannel.Literal(class="code-string">"SignalR");
});How It Works
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.
builder.Services.AddSignalR()
.AddAzureSignalR(builder.Configuration[class="code-string">"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
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($class="code-string">"Success: {result}");
}
catch (ValidationException ex)
{
class=class="code-string">"code-comment">// Send structured errors back to the caller
_logger.LogWarning(ex, class="code-string">"Validation failed for {ConnectionId}", Context.ConnectionId);
throw new HubException($class="code-string">"Validation failed: {ex.Message}");
}
catch (Exception ex)
{
class=class="code-string">"code-comment">// Log the full exception but send a safe message to the client
_logger.LogError(ex, class="code-string">"Unexpected error for {ConnectionId}", Context.ConnectionId);
throw new HubException(class="code-string">"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
const connection = new signalR.HubConnectionBuilder()
.withUrl(class="code-string">"/hubs/notifications")
.withAutomaticReconnect({
nextRetryDelayInMilliseconds: (retryContext) => {
if (retryContext.elapsedMilliseconds < class="code-number">60000) {
return Math.random() * class="code-number">10000; class=class="code-string">"code-comment">// Random delay up to class="code-number">10 seconds
}
return null; class=class="code-string">"code-comment">// Stop retrying after class="code-number">60 seconds
}
})
.build();
connection.onreconnecting((error) => {
showConnectionStatus(class="code-string">"Reconnecting...");
console.warn(class="code-string">"Connection lost, attempting reconnect:", error);
});
connection.onreconnected((connectionId) => {
showConnectionStatus(class="code-string">"Connected");
console.log(class="code-string">"Reconnected with ID:", connectionId);
class=class="code-string">"code-comment">// Rejoin groups after reconnection
rejoinGroups();
});
connection.onclose((error) => {
showConnectionStatus(class="code-string">"Disconnected");
console.error(class="code-string">"Connection closed:", error);
class=class="code-string">"code-comment">// Manual reconnect with exponential backoff
setTimeout(() => startConnection(), class="code-number">5000);
});
async function startConnection() {
try {
await connection.start();
showConnectionStatus(class="code-string">"Connected");
} catch (err) {
console.error(class="code-string">"Connection failed:", err);
setTimeout(() => startConnection(), class="code-number">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.
class=class="code-string">"code-comment">// WRONG -- this counter resets on every call
public class BadHub : Hub
{
private int _messageCount = class="code-number">0; class=class="code-string">"code-comment">// Always class="code-number">0 on each invocation
public async Task SendMessage(string msg)
{
_messageCount++; class=class="code-string">"code-comment">// Always class="code-number">1
await Clients.All.SendAsync(class="code-string">"Count", _messageCount);
}
}
class=class="code-string">"code-comment">// 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(class="code-string">"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.
builder.Services.AddCors(options =>
{
options.AddPolicy(class="code-string">"SignalRPolicy", policy =>
{
policy.WithOrigins(class="code-string">"https:class="code-commentclass="code-string">">//app.example.com")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); class=class="code-string">"code-comment">// 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.
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
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
Authentication and Authorization in .NET: JWT and Identity
Implement secure authentication and authorization in .NET. JWT, ASP.NET Core Identity, and OAuth2.
Caching Strategies in .NET: In-Memory, Distributed, and Redis
Implement effective caching strategies in .NET. In-memory cache, distributed cache, and Redis integration.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch