Real-time in .NET with SignalR brings instant, bidirectional communication to your applications without the complexity of managing raw WebSocket plumbing yourself. Whether you're building live dashboards, collaborative editing features, or multiplayer experiences, SignalR abstracts away transport details and scaling headaches. By day 76 of this series, you've mastered Kafka's event streaming—now it's time to learn how to push those events to connected clients instantly.
Hubs — Server methods clients call and client methods servers call
At the heart of SignalR is the Hub: a server-side class that clients connect to and invoke methods on. Unlike traditional HTTP, the connection is persistent and bidirectional. Your server can call methods on clients, and clients can invoke server methods. This symmetry is what makes real-time interactions feel natural.
Let me show you a practical example. Imagine you're building a ride-hailing platform where drivers update their location and passengers see it live.
// Server-side Hub in .NET
public class LocationHub : Hub
{
public async Task UpdateDriverLocation(string driverId, double latitude, double longitude)
{
// Persist to database or cache
await _locationService.SetDriverLocation(driverId, latitude, longitude);
// Notify all connected clients watching this driver
await Clients.Group($"driver-{driverId}")
.SendAsync("LocationUpdated", new { driverId, latitude, longitude });
}
public async Task NotifyPassenger(string passengerId, string message)
{
// Send to a specific client connection
await Clients.User(passengerId)
.SendAsync("ReceiveMessage", message);
}
}On the client side (React or Next.js), you establish a connection and listen for these method invocations:
// Client-side React
import * as signalR from "@microsoft/signalr";
const connection = new signalR.HubConnectionBuilder()
.withUrl("https://api.example.com/locationHub")
.withAutomaticReconnect()
.build();
connection.on("LocationUpdated", (data) => {
setDriverLocation(data);
});
connection.start().catch(err => console.error(err));
// Invoke server method
const updateLocation = async (lat, lon) => {
await connection.invoke("UpdateDriverLocation", "driver123", lat, lon);
};The key difference from REST: you don't poll. The server sends data to clients as it happens. This cuts latency from seconds to milliseconds and eliminates wasted requests.
Groups — Target tenants, rooms or users efficiently
Sending data to every connected client isn't practical. Groups let you partition connections logically: by tenant, by feature, by room, or by user role. When you add a connection to a group, only that group receives messages sent to it.
In a multi-tenant SaaS product, you'd never want customer A seeing customer B's data. Groups enforce that boundary at the framework level:
public class NotificationHub : Hub
{
private readonly ITenantService _tenantService;
public override async Task OnConnectedAsync()
{
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
var userId = Context.User?.FindFirst("sub")?.Value;
// Add this connection to tenant-specific group
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant-{tenantId}");
// Also add to user-specific group for 1:1 messages
await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}");
await base.OnConnectedAsync();
}
public async Task BroadcastToTenant(string message)
{
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
await Clients.Group($"tenant-{tenantId}")
.SendAsync("ReceiveNotification", message);
}
}This pattern scales beautifully. A platform with 100,000 concurrent users across 500 tenants can selectively notify only the relevant users without flooding the entire network. Removing a user is equally simple: call Groups.RemoveFromGroupAsync() on disconnect.
Transports — WebSockets first, with SSE and long polling fallbacks
SignalR negotiates the best transport for each client. WebSockets are the gold standard: full-duplex, low-latency, minimal overhead. But not all environments support them (corporate proxies, older browsers). SignalR gracefully falls back to Server-Sent Events (SSE) or long polling.
You don't need to handle this complexity. Configure it once at startup:
// Program.cs (.NET 6+)
builder.Services.AddSignalR(options =>
{
options.MaximumReceiveMessageSize = 64 * 1024 * 1024; // 64MB
options.ServerTimeout = TimeSpan.FromSeconds(30);
options.KeepAliveInterval = TimeSpan.FromSeconds(15);
})
.AddRedisBackplane("redis-connection-string");
app.MapHub("/locationHub");
app.MapHub("/notificationHub");The framework handles transport selection during the initial handshake. On the client, you can explicitly prefer a transport or let it auto-detect:
const connection = new signalR.HubConnectionBuilder()
.withUrl("https://api.example.com/locationHub", {
transport: signalR.HttpTransportType.WebSockets |
signalR.HttpTransportType.ServerSentEvents |
signalR.HttpTransportType.LongPolling
})
.withAutomaticReconnect([0, 2000, 5000, 10000]) // Exponential backoff
.build();WebSockets will be used if available. SSE is a good middle ground (unidirectional from server to client, works over HTTP/1.1). Long polling is the fallback but creates higher overhead since it relies on repeated requests.
Scale out — Redis backplane or Azure SignalR Service
A single .NET server can handle thousands of concurrent WebSocket connections. But when you scale to multiple app servers, a problem emerges: if Server A has a connected client and Server B needs to send that client a message, how does it reach them?
The answer is a backplane. All servers connect to a shared message broker (Redis, Azure Service Bus, or Azure SignalR Service). When any server sends a message to a group or client, the backplane ensures delivery across the cluster.
Redis backplane is lightweight and self-hosted. Add it with one line:
builder.Services.AddSignalR()
.AddRedisBackplane("connection-string-to-redis");
Now, regardless of which load balancer sends a client request to which app server, all servers can coordinate messages through Redis:
| Architecture | Pros | Cons | Best for |
|---|---|---|---|
| Single server | Simplest, no external deps | Can't scale horizontally | Prototypes, small products (<1k concurrent) |
| Redis backplane | Self-hosted, low latency, familiar | Adds Redis ops burden | Mid-scale products (1k–100k concurrent) |
| Azure SignalR Service | Fully managed, auto-scaling, no ops | Higher cost, vendor lock-in | Enterprise products or rapid scaling |
If you're on Azure, SignalR Service is compelling. It handles scale, failover, and diagnostics for you. The cost is higher, but operations burden drops to near-zero. For self-hosted, Redis is battle-tested and runs anywhere Docker runs.
Here's how to configure Azure SignalR Service:
builder.Services.AddSignalR()
.AddAzureSignalR(options =>
{
options.ConnectionString = "your-signalr-service-connection-string";
options.ServerStickyMode = ServerStickyMode.Preferred;
});
The framework handles connection sharding, message routing, and failover transparently.
Common mistakes
- Not using groups and broadcasting to all clients. This works in demos but becomes expensive fast. Always partition by tenant, feature, or user.
- Ignoring reconnection logic. WebSocket connections drop. Plan for automatic reconnection with exponential backoff. Your UI should queue messages during disconnects and replay them when reconnected.
- Forgetting to remove clients from groups. If you don't call
Groups.RemoveFromGroupAsync()inOnDisconnectedAsync, memory leaks grow. Always clean up. - Treating SignalR as a replacement for Kafka. SignalR is for pushing data to clients. Kafka is for event streaming and async processing. Use both: Kafka processes events, SignalR delivers results to browsers.
- Running without a backplane in production with multiple servers. This silently fails: some clients miss messages because they're on a server that didn't initiate the send. Always use Redis or Azure SignalR Service in multi-server deployments.
Key takeaways
- Hubs are bidirectional RPC endpoints: servers and clients call methods on each other without polling.
- Groups partition connections securely, enabling efficient targeting of tenants, rooms, or users.
- SignalR negotiates the best transport (WebSockets, SSE, or long polling) automatically; you choose based on environment constraints.
- Scale out with Redis backplane for self-hosted or Azure SignalR Service for managed convenience.
- Combine SignalR with Kafka: Kafka processes events asynchronously, SignalR pushes results to connected clients in real time.
- Always clean up group membership in
OnDisconnectedAsyncto prevent memory leaks in long-running servers.






Comments
Loading comments…