Real-Time Features with SignalR in .NET
Push live order status updates to clients using SignalR. Covers hub setup, JWT authentication over WebSockets, group-based broadcasting, domain event integration, and a React client.
Why Real-Time in OrderFlow?
Without SignalR, an order's status update is invisible to the client until they refresh:
Customer places order → Warehouse picks it → Status changes to "Processing"
Customer has no idea — they're staring at "Confirmed" until manual refreshWith SignalR:
Status changes to "Processing" in the database
→ SignalR pushes "OrderStatusChanged" event to the customer's browser
→ UI updates instantly — no polling, no refreshSetup
dotnet add OrderFlow.Api package Microsoft.AspNetCore.SignalRSignalR ships with ASP.NET Core — no extra package needed for the server. The JavaScript client needs one:
# In your React project
npm install @microsoft/signalrThe OrderFlow Hub
// OrderFlow.Api/Hubs/OrderHub.cs
public class OrderHub(ILogger<OrderHub> logger) : Hub
{
// Called when a client connects
public override async Task OnConnectedAsync()
{
var userId = Context.User?.FindFirstValue(JwtRegisteredClaimNames.Sub);
if (userId is not null)
{
// Each customer joins their own group — only receives their own order updates
await Groups.AddToGroupAsync(Context.ConnectionId, $"customer:{userId}");
logger.LogInformation("User {UserId} connected to OrderHub", userId);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.User?.FindFirstValue(JwtRegisteredClaimNames.Sub);
if (userId is not null)
logger.LogInformation("User {UserId} disconnected from OrderHub", userId);
await base.OnDisconnectedAsync(exception);
}
// Admins can subscribe to all orders for a live dashboard
public async Task JoinAdminDashboard()
{
if (Context.User?.IsInRole("Admin") != true)
{
Context.Abort();
return;
}
await Groups.AddToGroupAsync(Context.ConnectionId, "admin:dashboard");
}
}Register SignalR + JWT Authentication
SignalR uses query strings to pass tokens (WebSockets can't set HTTP headers):
// OrderFlow.Api/Program.cs
builder.Services.AddSignalR();
// In JWT configuration — extract token from query string for SignalR
services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(opt =>
{
opt.TokenValidationParameters = /* ... same as before ... */;
// SignalR sends the token in the query string
opt.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 the hub endpoint
app.MapHub<OrderHub>("/hubs/orders").RequireAuthorization();Pushing Updates from Domain Event Handlers
The cleanest place to push SignalR messages is in domain event handlers — the same pipeline that already handles emails, stock reservations, and cache invalidation:
// OrderFlow.Application/Orders/Events/PushOrderStatusUpdateHandler.cs
public class PushOrderStatusUpdateHandler(
IHubContext<OrderHub> hubContext,
ILogger<PushOrderStatusUpdateHandler> logger)
: INotificationHandler<OrderStatusChangedEvent>
{
public async Task Handle(OrderStatusChangedEvent notification, CancellationToken ct)
{
var payload = new OrderStatusUpdate(
OrderId: notification.OrderId,
OrderNumber: notification.OrderNumber,
NewStatus: notification.NewStatus.ToString(),
UpdatedAt: DateTime.UtcNow);
// Push to the customer's group
await hubContext.Clients
.Group($"customer:{notification.CustomerId}")
.SendAsync("OrderStatusChanged", payload, ct);
// Also push to admin dashboard
await hubContext.Clients
.Group("admin:dashboard")
.SendAsync("OrderStatusChanged", payload, ct);
logger.LogInformation(
"Pushed status update for order {OrderNumber}: {Status}",
notification.OrderNumber, notification.NewStatus);
}
}
public record OrderStatusUpdate(
Guid OrderId,
string OrderNumber,
string NewStatus,
DateTime UpdatedAt);The Domain Event
Add a status-changed event to the Order aggregate:
// OrderFlow.Domain/Events/OrderStatusChangedEvent.cs
public record OrderStatusChangedEvent(
Guid OrderId,
Guid CustomerId,
string OrderNumber,
OrderStatus OldStatus,
OrderStatus NewStatus) : IDomainEvent;Raise it inside the Order entity whenever status changes:
// OrderFlow.Domain/Entities/Order.cs
public void Confirm()
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException($"Order is {Status}, not Draft.");
if (_lines.Count == 0)
throw new InvalidOperationException("Cannot confirm an empty order.");
var oldStatus = Status;
Status = OrderStatus.Confirmed;
ConfirmedAt = DateTime.UtcNow;
RaiseDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Customer.Email, OrderNumber, Total, ConfirmedAt.Value));
RaiseDomainEvent(new OrderStatusChangedEvent(Id, CustomerId, OrderNumber, oldStatus, Status));
}
public void Ship()
{
if (Status != OrderStatus.Processing)
throw new InvalidOperationException("Order must be Processing before it can ship.");
var oldStatus = Status;
Status = OrderStatus.Shipped;
RaiseDomainEvent(new OrderStatusChangedEvent(Id, CustomerId, OrderNumber, oldStatus, Status));
}
public void Deliver()
{
if (Status != OrderStatus.Shipped)
throw new InvalidOperationException("Order must be Shipped before it can be delivered.");
var oldStatus = Status;
Status = OrderStatus.Delivered;
RaiseDomainEvent(new OrderStatusChangedEvent(Id, CustomerId, OrderNumber, oldStatus, Status));
}Sending Typed Messages
SignalR's IHubContext<THub> uses string method names — typos cause silent failures. Use a strongly typed client interface:
// OrderFlow.Api/Hubs/IOrderHubClient.cs
public interface IOrderHubClient
{
Task OrderStatusChanged(OrderStatusUpdate update);
Task NewOrderCreated(OrderCreatedNotification notification);
Task LowStockAlert(LowStockNotification notification);
}
// Typed hub
public class OrderHub : Hub<IOrderHubClient>
{
// ... same as before, but Hub<IOrderHubClient> instead of Hub
}
// Typed context in handlers
public class PushOrderStatusUpdateHandler(
IHubContext<OrderHub, IOrderHubClient> hubContext)
: INotificationHandler<OrderStatusChangedEvent>
{
public async Task Handle(OrderStatusChangedEvent notification, CancellationToken ct)
{
var payload = new OrderStatusUpdate(
notification.OrderId,
notification.OrderNumber,
notification.NewStatus.ToString(),
DateTime.UtcNow);
// No string — compile-time checked
await hubContext.Clients
.Group($"customer:{notification.CustomerId}")
.OrderStatusChanged(payload);
}
}React Client
// src/hooks/useOrderHub.ts
import {
HubConnection,
HubConnectionBuilder,
LogLevel,
} from "@microsoft/signalr";
import { useEffect, useRef, useState } from "react";
interface OrderStatusUpdate {
orderId: string;
orderNumber: string;
newStatus: string;
updatedAt: string;
}
export function useOrderHub(accessToken: string | null) {
const connectionRef = useRef<HubConnection | null>(null);
const [updates, setUpdates] = useState<OrderStatusUpdate[]>([]);
const [connected, setConnected] = useState(false);
useEffect(() => {
if (!accessToken) return;
const connection = new HubConnectionBuilder()
.withUrl("https://localhost:7001/hubs/orders", {
accessTokenFactory: () => accessToken, // passed as ?access_token=...
})
.withAutomaticReconnect()
.configureLogging(LogLevel.Warning)
.build();
connection.on("OrderStatusChanged", (update: OrderStatusUpdate) => {
setUpdates((prev) => [update, ...prev.slice(0, 49)]); // keep last 50
});
connection
.start()
.then(() => setConnected(true))
.catch((err) => console.error("SignalR connection failed:", err));
connectionRef.current = connection;
return () => {
connection.stop();
setConnected(false);
};
}, [accessToken]);
return { updates, connected };
}// src/components/OrderStatusFeed.tsx
import { useOrderHub } from "../hooks/useOrderHub";
import { useAuth } from "../hooks/useAuth";
export function OrderStatusFeed() {
const { accessToken } = useAuth();
const { updates, connected } = useOrderHub(accessToken);
return (
<div>
<div className="flex items-center gap-2 mb-4">
<span className={`w-2 h-2 rounded-full ${connected ? "bg-green-400" : "bg-red-400"}`} />
<span className="text-sm text-gray-500">
{connected ? "Live updates connected" : "Connecting..."}
</span>
</div>
{updates.map((u, i) => (
<div key={i} className="border-l-2 border-blue-400 pl-3 mb-3">
<p className="font-medium">Order {u.orderNumber}</p>
<p className="text-sm text-gray-600">
Status: <span className="font-semibold">{u.newStatus}</span>
</p>
<p className="text-xs text-gray-400">
{new Date(u.updatedAt).toLocaleTimeString()}
</p>
</div>
))}
{updates.length === 0 && (
<p className="text-gray-400 text-sm">Waiting for order updates...</p>
)}
</div>
);
}Admin Dashboard — Broadcast to All Admins
// Push a new order notification to all connected admins
public class NotifyAdminOnNewOrderHandler(
IHubContext<OrderHub, IOrderHubClient> hubContext)
: INotificationHandler<OrderCreatedEvent>
{
public async Task Handle(OrderCreatedEvent notification, CancellationToken ct)
{
await hubContext.Clients
.Group("admin:dashboard")
.NewOrderCreated(new OrderCreatedNotification(
OrderId: notification.OrderId,
OrderNumber: notification.OrderNumber,
CustomerName: notification.CustomerName,
Total: notification.Total,
CreatedAt: DateTime.UtcNow));
}
}Low Stock Alerts
Another great use case — push alerts to warehouse staff when stock drops below threshold:
// OrderFlow.Application/Products/Events/LowStockAlertHandler.cs
public class LowStockAlertHandler(
IHubContext<OrderHub, IOrderHubClient> hubContext)
: INotificationHandler<StockLevelChangedEvent>
{
private const int LowStockThreshold = 10;
public async Task Handle(StockLevelChangedEvent notification, CancellationToken ct)
{
if (notification.NewLevel < LowStockThreshold && notification.OldLevel >= LowStockThreshold)
{
await hubContext.Clients
.Group("admin:dashboard")
.LowStockAlert(new LowStockNotification(
ProductId: notification.ProductId,
Sku: notification.Sku,
StockLevel: notification.NewLevel));
}
}
}CORS Configuration for SignalR
SignalR requires specific CORS settings — AllowAnyOrigin doesn't work with credentials:
builder.Services.AddCors(opt =>
opt.AddDefaultPolicy(policy =>
policy
.WithOrigins("https://localhost:3000", "https://orderflow.app")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials())); // Required for SignalRKey Takeaways
- SignalR hubs are the server-side WebSocket endpoint — clients connect once and receive pushed messages
- Group-based broadcasting (
Groups.AddToGroupAsync) isolates updates — each customer only sees their own orders - JWT authentication over WebSockets requires the token in the query string (
?access_token=...), not the Authorization header - Domain events drive SignalR pushes —
PushOrderStatusUpdateHandler : INotificationHandler<OrderStatusChangedEvent>keeps push logic out of command handlers - Typed hub clients (
Hub<IOrderHubClient>) give compile-time safety — no string typos causing silent message failures withAutomaticReconnect()in the JS client handles network drops transparently — the browser reconnects without user action
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.