Real-time with SignalR: Why You Need It and Why It Matters
Building live dashboards and notifications in .NET used to mean polling databases every few seconds or wrestling with complex WebSocket plumbing. Real-time with SignalR changes that equation: you get a battle-tested abstraction over WebSockets, Server-Sent Events, and long polling that handles fallbacks automatically, reconnection logic, and message routing for you. In this guide, I'll show you how to architect production-grade real-time features—from strongly typed hubs to authentication and multi-tenant broadcasting—so your dashboards stay fresh and your users stay engaged without burning through your infrastructure budget.
Strongly Typed Hubs: Compile-Time Safety on the Wire
The real power of SignalR emerges when you define a client interface that both your server and JavaScript client can reference. Instead of magic strings and pray-you-spelled-it-right method names, you get IntelliSense, compile-time checking, and a clear contract for what methods clients must implement.
Here's how I structure it. First, define an interface that lives in a shared location—typically in a .NET project that both your API and client can see (or replicate):
public interface IOrderClient
{
Task OrderUpdated(OrderDto order);
Task OrderCancelled(string orderId);
Task NotificationReceived(NotificationDto notification);
}
Then your hub becomes strongly typed against that interface:
public class OrderHub : Hub<IOrderClient>
{
public async Task PlaceOrder(CreateOrderRequest request)
{
// Business logic
var order = await _orderService.CreateOrder(request);
// Notify all clients about the new order
await Clients.All.OrderUpdated(order);
}
public async Task CancelOrder(string orderId)
{
await _orderService.CancelOrder(orderId);
// Notify all clients
await Clients.All.OrderCancelled(orderId);
}
}
The benefits are concrete. Rename OrderUpdated to OrderChanged, and your entire codebase—server and client—breaks at compile time. No silent bugs. No 3am alerts because JavaScript called a method that no longer exists. In production platforms with dozens of hubs and hundreds of connections, this type safety saves hours of debugging.
On the JavaScript side (whether vanilla, React, or Vue), you'll use the SignalR client library which will auto-generate types if you're using TypeScript:
const connection = new signalR.HubConnectionBuilder()
.withUrl("/orderhub")
.withAutomaticReconnect()
.build();
connection.on("OrderUpdated", (order) => {
console.log("Order updated:", order);
// Update your UI
});
await connection.start();
Groups for Targeting: Broadcast to Tenants, Not to the World
When you're running a SaaS platform with hundreds of organizations, broadcasting to Clients.All is a disaster. Tenant A's order updates would flood Tenant B's dashboard. That's why SignalR provides groups—a first-class way to partition your broadcast audience.
In a multi-tenant scenario, I add each connection to a group named after the tenant the moment they connect:
public class OrderHub : Hub<IOrderClient>
{
public override async Task OnConnectedAsync()
{
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
if (string.IsNullOrEmpty(tenantId))
{
throw new UnauthorizedAccessException("No tenant in token");
}
// Add connection to tenant group
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception exception)
{
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
if (!string.IsNullOrEmpty(tenantId))
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
}
await base.OnDisconnectedAsync(exception);
}
}
Now when you push an update for a specific tenant's order, you broadcast only to that tenant's group:
public async Task NotifyOrderUpdate(string tenantId, OrderDto order)
{
await Clients.Group($"tenant:{tenantId}")
.OrderUpdated(order);
}
This pattern scales beautifully. I've used it for ride-hailing platforms (driver:123, rider:456), trading systems (portfolio:user-789), and travel apps (booking:agent-101). Groups can be nested or layered—for example, tenant:42:support-team and tenant:42:finance-team—to enable role-based or team-based broadcasting without fetching connection lists from a database.
Send from Anywhere: Injecting HubContext into Services
Your business logic lives in services, but your hub methods are the only place with direct access to connected clients. SignalR solves this with IHubContext<THub, TClient>—inject it anywhere and push updates without being inside a hub method.
Imagine an order processing service that runs in the background (triggered by a Kafka event, a scheduled job, or a separate API call). You want to notify clients the instant an order ships:
public class OrderProcessingService
{
private readonly IHubContext<OrderHub, IOrderClient> _hubContext;
private readonly IOrderRepository _orderRepository;
public OrderProcessingService(
IHubContext<OrderHub, IOrderClient> hubContext,
IOrderRepository orderRepository)
{
_hubContext = hubContext;
_orderRepository = orderRepository;
}
public async Task ProcessShipmentAsync(string orderId)
{
var order = await _orderRepository.GetOrderAsync(orderId);
// Ship the order
order.Status = OrderStatus.Shipped;
order.ShippedAt = DateTime.UtcNow;
await _orderRepository.UpdateAsync(order);
// Notify all connected clients in that tenant
var dto = new OrderDto { Id = order.Id, Status = order.Status };
await _hubContext.Clients
.Group($"tenant:{order.TenantId}")
.OrderUpdated(dto);
}
}
Register it in your DI container and SignalR handles the rest:
builder.Services.AddSignalR();
builder.Services.AddScoped<OrderProcessingService>();
This decouples your event logic from your presentation layer. Your order service doesn't know or care about hubs—it just knows that after an update, the notification gets sent. In production systems handling tens of thousands of orders daily, this separation is crucial for testing, scaling, and maintaining mental clarity.
Authenticate Connections: JWT via Query Parameters and OnConnected
SignalR connections are established over WebSocket, and WebSocket handshakes don't support the same header-based authentication that HTTP rest calls do. You'll pass the JWT as a query parameter and validate it in OnConnectedAsync.
On the client side, include the token:
const token = localStorage.getItem("access_token");
const connection = new signalR.HubConnectionBuilder()
.withUrl("/orderhub?access_token=" + token)
.withAutomaticReconnect()
.build();
On the server, configure the hub to extract and validate the JWT:
public class OrderHub : Hub<IOrderClient>
{
private readonly ITokenValidator _tokenValidator;
public OrderHub(ITokenValidator tokenValidator)
{
_tokenValidator = tokenValidator;
}
public override async Task OnConnectedAsync()
{
var httpContext = Context.GetHttpContext();
var token = httpContext?.Request.Query["access_token"];
if (string.IsNullOrEmpty(token))
{
throw new UnauthorizedAccessException("No token provided");
}
var principal = _tokenValidator.ValidateToken(token);
if (principal == null)
{
throw new UnauthorizedAccessException("Invalid token");
}
// Attach the principal to the context
Context.User = principal;
await base.OnConnectedAsync();
}
}
If you want to validate on every message (stricter but more defensive), override OnMessageReceivedAsync in a custom pipeline. But in my experience, validating at connection time is sufficient for most workloads—you're trading defense-in-depth for reduced latency overhead.
For production, always validate the token signature against your key provider and check expiration. I also recommend short token lifetimes (15–30 minutes) and a refresh mechanism so your dashboard connections don't stale out.
Wiring It All Together: A Complete Example
Here's how all the pieces fit into a minimal but realistic application:
// Program.cs
builder.Services.AddSignalR();
builder.Services.AddScoped<OrderProcessingService>();
app.MapHub<OrderHub>("/orderhub");
// OrderHub.cs
public class OrderHub : Hub<IOrderClient>
{
private readonly OrderProcessingService _orderService;
public OrderHub(OrderProcessingService orderService)
{
_orderService = orderService;
}
public override async Task OnConnectedAsync()
{
var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
if (string.IsNullOrEmpty(tenantId))
throw new UnauthorizedAccessException();
await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant:{tenantId}");
await base.OnConnectedAsync();
}
public async Task PlaceOrder(CreateOrderRequest request)
{
var order = await _orderService.CreateOrderAsync(request);
await Clients.Group($"tenant:{order.TenantId}")
.OrderUpdated(order);
}
}
// OrderProcessingService.cs
public class OrderProcessingService
{
private readonly IHubContext<OrderHub, IOrderClient> _hubContext;
private readonly IOrderRepository _orders;
public OrderProcessingService(
IHubContext<OrderHub, IOrderClient> hubContext,
IOrderRepository orders)
{
_hubContext = hubContext;
_orders = orders;
}
public async Task ProcessShipmentAsync(string orderId)
{
var order = await _orders.GetOrderAsync(orderId);
order.Status = OrderStatus.Shipped;
await _orders.UpdateAsync(order);
await _hubContext.Clients
.Group($"tenant:{order.TenantId}")
.OrderUpdated(new OrderDto
{
Id = order.Id,
Status = order.Status
});
}
}
Common Mistakes
Broadcasting to all clients when you meant to target a group. I've seen dashboards show order updates across tenants because someone used Clients.All instead of Clients.Group(...). Always scope your broadcasts. Add a code review checklist item: "Is this broadcast intentionally going to all users?"
Forgetting to handle reconnections. Mobile networks drop frequently. If your client reconnects but doesn't re-add itself to groups, it'll miss updates silently. Configure .withAutomaticReconnect() on the client and make sure OnConnectedAsync always re-establishes group membership.
Passing oversized objects to Clients. Each message gets serialized to JSON and sent over the network. If your OrderDto includes nested collections with thousands of items, you'll throttle your own connection. Keep payloads lean—send IDs, not full objects, and let clients fetch details if needed.
Storing connection IDs in the database as a "presence" mechanism. Connection IDs are volatile and unique per process. In a scaled deployment with multiple API instances, they're meaningless. Use a durable presence store (Redis) or track connections in memory with patterns like ConcurrentDictionary<string, string> per instance.
Key Takeaways
- Use strongly typed hubs with client interfaces to get compile-time safety and self-documenting contracts.
- Leverage groups to partition broadcasts—critical for multi-tenant systems and scaling.
- Inject
IHubContext<THub, TClient>into services so your business logic can trigger real-time updates without coupling to hub methods. - Authenticate WebSocket connections via JWT query parameters and validate in
OnConnectedAsync, keeping token lifetime short. - Always handle reconnections on the client and re-establish group membership automatically.
- Keep message payloads small—send IDs and summaries, not entire object graphs.






Comments
Loading comments…