.NET & C# Development · Lesson 81 of 92

Push Live Order Updates to the Browser With SignalR

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 refresh

With SignalR:

Status changes to "Processing" in the database
→ SignalR pushes "OrderStatusChanged" event to the customer's browser
→ UI updates instantly — no polling, no refresh

Setup

Bash
dotnet add OrderFlow.Api package Microsoft.AspNetCore.SignalR

SignalR ships with ASP.NET Core — no extra package needed for the server. The JavaScript client needs one:

Bash
# In your React project
npm install @microsoft/signalr

The OrderFlow Hub

C#
// 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):

C#
// 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:

C#
// 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:

C#
// 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:

C#
// 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:

C#
// 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

TSX
// 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 };
}
TSX
// 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

C#
// 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:

C#
// 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:

C#
builder.Services.AddCors(opt =>
    opt.AddDefaultPolicy(policy =>
        policy
            .WithOrigins("https://localhost:3000", "https://orderflow.app")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials()));   // Required for SignalR

Key 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 pushesPushOrderStatusUpdateHandler : 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
Lesson Checkpoint
Quick CheckQuestion 1 of 4

Why does SignalR use the query string for JWT tokens instead of the Authorization header?