Intermediate

ASP.NET Core SignalR Fundamentals

Real-time apps with SignalR — hubs, transports, strongly typed hubs, clients, auth and scaling.

Version: ASP.NET Core 8 (100% applicable to ASP.NET Core 5+) Demo Application: Real-Time Auction System


Table of Contents

  1. Introduction — The Power of SignalR
  2. Hub and RPC Architecture
  3. Transports and Negotiation
  4. Connection Lifecycle
  5. Strongly Typed Hubs
  6. Advanced Server Features
  7. JavaScript Client — Complete Guide
  8. .NET Client — Complete Guide
  9. Authentication and Authorization
  10. Hosting and Scaling
  11. Performance and Advanced Configuration
  12. Demo Application — Full Auction System
  13. Summary and Best Practices
  14. Review Questions

1. Introduction — The Power of SignalR

1.1 The Problem with Traditional Web Applications

In a traditional web application, only the client initiates exchanges with the server. If data changes server-side (e.g., another user places a bid), other clients are not notified unless they manually refresh the page.

sequenceDiagram
    participant ClientA as Browser A
    participant Server as ASP.NET Core Server
    participant ClientB as Browser B

    ClientA->>Server: GET /auctions (manual refresh)
    Server-->>ClientA: HTML with auction list

    Note over ClientB: Places a bid
    ClientB->>Server: POST /auction/1/bid?value=150
    Server-->>ClientB: 200 OK

    Note over ClientA: Unaware of the new bid!
    ClientA->>Server: GET /auctions (must refresh manually)
    Server-->>ClientA: HTML with updated data

1.2 What SignalR Provides

SignalR allows the server to push data to clients as soon as a change occurs — without clients needing to refresh or poll periodically.

sequenceDiagram
    participant A as Browser A
    participant Hub as AuctionHub (SignalR)
    participant B as Browser B
    participant Console as Console App (.NET)

    A->>Hub: Connection established (connectionId: abc123)
    B->>Hub: Connection established (connectionId: def456)
    Console->>Hub: Connection established (connectionId: ghi789)

    A->>Hub: invoke(NotifyNewBid, {auctionId:1, newBid:150})
    Hub-->>A: ReceiveNewBid(auction)
    Hub-->>B: ReceiveNewBid(auction) — real-time update
    Hub-->>Console: ReceiveNewBid(auction) — console update

1.3 Typical Use Cases

DomainExamples
E-commerceAuction systems, real-time inventory
MonitoringLive dashboards, streaming logs
CollaborationChat applications, collaborative editing
FinanceStock prices, cryptocurrencies
GamingOnline multiplayer games
DevOpsCI/CD pipelines, deployment progress
IoTReal-time sensor data

1.4 SignalR Alternatives (when not to use it)

TechnologyWhen to useLimitation vs SignalR
Manual pollingRarely changing dataInefficient, delayed
Native Server-Sent EventsUnidirectional stream onlyNo client→server push
Raw WebSocketsFull control, custom protocolNo abstraction, more complex
gRPC streamingInternal microservicesNot suited for browsers

SignalR is the right choice when you need real-time bidirectional communication in an ASP.NET Core application, with automatic multi-transport support.


2. Hub and RPC Architecture

2.1 What is a Hub?

A Hub is a class derived from ASP.NET Core’s Hub base class. It maintains persistent bidirectional connections with clients and acts as a central relay between all connected clients.

graph TD
    BrowserA["Browser A\n(JS Client)"] <-->|Bidirectional connection| HubClass["AuctionHub\n(derives from Hub)"]
    BrowserB["Browser B\n(JS Client)"] <-->|Bidirectional connection| HubClass
    Console["Console App\n(.NET Client)"] <-->|Bidirectional connection| HubClass
    Blazor["Blazor WASM\n(Client)"] <-->|Bidirectional connection| HubClass

    style HubClass fill:#2196F3,color:white

Important restriction: The Hub must be hosted in a server-side application. A pure Blazor WebAssembly project cannot host a Hub (but can be a client).

2.2 Creating a Minimal Hub

// AuctionHub.cs
using Microsoft.AspNetCore.SignalR;

public class AuctionHub : Hub
{
    // Method callable from clients (RPC — Remote Procedure Call)
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        // Call ReceiveNewBid on ALL connected clients
        await Clients.All.SendAsync("ReceiveNewBid", bidInfo);
    }
}

public record BidInfo(int AuctionId, int NewBid);

2.3 Registration in Program.cs

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// 1. Add MVC services
builder.Services.AddControllersWithViews();

// 2. Add SignalR to DI
builder.Services.AddSignalR();

// 3. Register other services
builder.Services.AddSingleton<IAuctionRepository, InMemoryAuctionRepository>();

var app = builder.Build();

app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

app.MapDefaultControllerRoute();

// 4. Map the Hub to a URL endpoint
app.MapHub<AuctionHub>("/auctionhub");

app.Run();

2.4 RPC (Remote Procedure Call) Principle

graph LR
    subgraph "Browser (JavaScript)"
        JsCall["connection.invoke\n('NotifyNewBid', bidInfo)"]
        JsReceive["connection.on\n('ReceiveNewBid', handler)"]
    end

    subgraph "Server (C#)"
        HubMethod["Task NotifyNewBid\n(BidInfo bidInfo)"]
        ClientsCall["Clients.All.SendAsync\n('ReceiveNewBid', auction)"]
    end

    JsCall -->|RPC call| HubMethod
    HubMethod --> ClientsCall
    ClientsCall -->|reverse RPC call| JsReceive

2.5 Hub Protocol Format (JSON)

// Type 1 message: Method invocation (client → server)
{
    "type": 1,
    "invocationId": "1",
    "target": "NotifyNewBid",
    "arguments": [{ "auctionId": 42, "newBid": 150 }]
}

// Type 1 message: Client invocation (server → client)
{
    "type": 1,
    "target": "ReceiveNewBid",
    "arguments": [{ "auctionId": 42, "newBid": 150, "itemName": "Antique vase" }]
}

// Type 6 message: KeepAlive (automatic ping/pong)
{ "type": 6 }

// Type 3 message: Completion result
{
    "type": 3,
    "invocationId": "1",
    "result": null,
    "error": null
}

Debugging tip: In Chrome DevTools → Network → WS → Messages, you can see all these messages flowing in real time.


3. Transports and Negotiation

3.1 The Three SignalR Transports

graph TD
    subgraph WS ["WebSockets ✅ Recommended"]
        WS1["Persistent TCP connection"]
        WS2["Full duplex — simultaneous in both directions"]
        WS3["Single TCP after HTTP 101 upgrade"]
    end

    subgraph SSE ["Server-Sent Events ⚠️ Fallback 1"]
        SSE1["One persistent HTTP connection server→client"]
        SSE2["Normal HTTP requests client→server"]
        SSE3["Half duplex — not simultaneous"]
    end

    subgraph LP ["Long Polling ⚠️ Fallback 2"]
        LP1["Repeated HTTP requests"]
        LP2["Server waits before responding"]
        LP3["Very inefficient — 1 TCP per message"]
    end
TransportDuplexTCPCompatibilityUsage
WebSocketsFullPersistent95%+Default
Server-Sent EventsServer→Client1 connectionModern browsersFallback 1
Long PollingSimulated1 per messageUniversalFallback 2

3.2 Automatic Negotiation Flow

sequenceDiagram
    participant C as Client (JS)
    participant S as SignalR Server

    C->>S: POST /auctionhub/negotiate?negotiateVersion=1
    S-->>C: 200 {"connectionId":"abc123","availableTransports":[...]}

    Note over C,S: Attempt 1 — WebSockets (best transport)
    C->>S: GET /auctionhub?id=abc123\n[Upgrade: websocket]

    alt WebSockets available
        S-->>C: 101 Switching Protocols
        Note over C,S: WebSocket connection established
    else WebSockets not available
        Note over C,S: Attempt 2 — Server-Sent Events
        C->>S: GET /auctionhub?id=abc123\n[Accept: text/event-stream]
        S-->>C: 200 OK (SSE stream)
    else SSE also unavailable
        Note over C,S: Fallback — Long Polling
        C->>S: GET /auctionhub?id=abc123 (long poll)
        S-->>C: Responds when a message arrives
    end

3.3 Forcing a Specific Transport

// JavaScript side — Force WebSockets only (avoids initial negotiation)
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub", {
        transport: signalR.HttpTransportType.WebSockets
    })
    .build();

// Disable Long Polling only
const connection2 = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub", {
        transport: signalR.HttpTransportType.WebSockets |
                   signalR.HttpTransportType.ServerSentEvents
    })
    .build();
// Server side — Restrict available transports
app.MapHub<AuctionHub>("/auctionhub", options =>
{
    options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});

3.4 Sticky Sessions (ARR Affinity)

Problem: With SSE or Long Polling, each HTTP request may arrive at a different server behind a load balancer.

graph LR
    subgraph "Without Sticky Sessions (PROBLEM)"
        LB1["Load Balancer"] -->|Req 1| S1["Server 1\n(connection context)"]
        LB1 -->|Req 2 - same client!| S2["Server 2\n(no context)"]
        S2 -.->|ERROR| Err["Unknown connection"]
    end

    subgraph "With Sticky Sessions (CORRECT)"
        LB2["Load Balancer\n+ ARR_AFFINITY Cookie"] -->|Always| S3["Server 1\n(same client always here)"]
    end

4. Connection Lifecycle

4.1 State Machine — SignalR Connection

stateDiagram-v2
    [*] --> Disconnected : Initial state

    Disconnected --> Connecting : connection.start()
    Connecting --> Connected : Connection succeeded\nOnConnectedAsync()
    Connecting --> Disconnected : Connection failed

    Connected --> Reconnecting : Network loss
    Connected --> Disconnected : connection.stop()\nOnDisconnectedAsync(null)
    Connected --> Disconnected : Fatal error\nOnDisconnectedAsync(exception)

    Reconnecting --> Connected : Reconnection succeeded\nOnConnectedAsync() — NEW ID
    Reconnecting --> Disconnected : Reconnection failed\nafter all attempts

Warning: On each reconnection, a new ConnectionId is generated. Do not store ConnectionId as a permanent user identifier.

4.2 OnConnectedAsync and OnDisconnectedAsync

public class AuctionHub : Hub
{
    private readonly IAuctionRepository _repository;
    private readonly ILogger<AuctionHub> _logger;

    public AuctionHub(IAuctionRepository repository, ILogger<AuctionHub> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    // Called automatically on every new connection
    public override async Task OnConnectedAsync()
    {
        var connectionId = Context.ConnectionId;
        var userId = Context.UserIdentifier; // null if unauthenticated

        _logger.LogInformation(
            "New connection: {ConnectionId}, User: {UserId}",
            connectionId, userId);

        // Send initial data to the client that just connected
        var auctions = await _repository.GetAllAsync();
        await Clients.Caller.SendAsync("InitialData", auctions);

        // Notify others that a user just connected
        await Clients.Others.SendAsync("UserConnected", userId);

        // IMPORTANT: always call base
        await base.OnConnectedAsync();
    }

    // Called on disconnection (exception = null if normal disconnection)
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        if (exception is null)
            _logger.LogInformation("Normal disconnection: {ConnectionId}", Context.ConnectionId);
        else
            _logger.LogWarning(exception, "Disconnection with error: {ConnectionId}", Context.ConnectionId);

        // SignalR groups are automatically cleaned up on disconnection
        await Clients.Others.SendAsync("UserDisconnected", Context.UserIdentifier);

        await base.OnDisconnectedAsync(exception);
    }
}

4.3 Connection ID vs User Identifier

graph TD
    subgraph "Identifiers available in a Hub"
        ConnId["Context.ConnectionId\nUnique ID per physical connection\nChanges on every reconnection\nEx: 'abc123xyz'"]
        UserId["Context.UserIdentifier\nStable user ID\nBased on NameIdentifier claim\nSame value even after reconnection"]
        UserObj["Context.User\nFull ClaimsPrincipal\nAll user claims"]
    end

    style ConnId fill:#FF9800,color:white
    style UserId fill:#4CAF50,color:white
public class AuctionHub : Hub
{
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        string connectionId = Context.ConnectionId;  // Unique to this connection
        string? userId = Context.UserIdentifier;      // Stable for the user

        // Send to all connections of a specific user
        // (user may have multiple tabs open!)
        await Clients.User("user-id-123").SendAsync("ReceiveNewBid", bidInfo);

        // Send to a specific connection
        await Clients.Client(connectionId).SendAsync("ReceiveNewBid", bidInfo);
    }
}

5. Strongly Typed Hubs

5.1 The Problem with Magic Strings

With SendAsync("ReceiveNewBid", ...), we use strings for method names — no compile-time checking, no IntelliSense, risk of silent typos.

5.2 Define a Client Interface

// IAuctionHubClient.cs — Interface defining CLIENT methods
public interface IAuctionHubClient
{
    // Methods the SERVER can call on CLIENTS
    Task ReceiveNewBid(BidInfo bidInfo);
    Task ReceiveNewAuction(Auction auction);
    Task NotifyOutbid(int auctionId);
    Task BidConfirmed(BidInfo bidInfo);
    Task InitialData(IEnumerable<Auction> auctions);
    Task UserConnected(string? userId);
    Task UserDisconnected(string? userId);
}

5.3 Strongly Typed Hub

// AuctionHub.cs — Hub<T> for compile-time checking
public class AuctionHub : Hub<IAuctionHubClient>
{
    private readonly IAuctionRepository _repository;

    public AuctionHub(IAuctionRepository repository)
    {
        _repository = repository;
    }

    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        var groupName = $"auction-{bidInfo.AuctionId}";

        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);

        // Strongly typed — no more magic strings, IntelliSense available!
        await Clients.All.ReceiveNewBid(bidInfo);
        await Clients.OthersInGroup(groupName).NotifyOutbid(bidInfo.AuctionId);
        await Clients.Caller.BidConfirmed(bidInfo);
    }

    public override async Task OnConnectedAsync()
    {
        var auctions = await _repository.GetAllAsync();
        await Clients.Caller.InitialData(auctions);
        await base.OnConnectedAsync();
    }
}

5.4 Typed IHubContext

// Inject IHubContext<THub, TClient> for full typing
app.MapPost("/auction", async (
    Auction auction,
    IAuctionRepository repository,
    IHubContext<AuctionHub, IAuctionHubClient> hubContext) =>
{
    await repository.AddAsync(auction);

    // Full typing — no SendAsync with strings!
    await hubContext.Clients.All.ReceiveNewAuction(auction);

    return Results.Created($"/auction/{auction.Id}", auction);
});

Key advantage: If you rename ReceiveNewBid in the interface, the compiler tells you everywhere the interface is no longer satisfied.


6. Advanced Server Features

6.1 Groups — Targeted Sending

public class AuctionHub : Hub<IAuctionHubClient>
{
    // Add a client to a group
    public async Task JoinAuctionGroup(int auctionId)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, $"auction-{auctionId}");
    }

    // Remove a client from a group
    public async Task LeaveAuctionGroup(int auctionId)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"auction-{auctionId}");
    }

    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        var groupName = $"auction-{bidInfo.AuctionId}";

        // Add the bidder to the group
        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);

        // Notify ALL clients (yellow animation)
        await Clients.All.ReceiveNewBid(bidInfo);

        // Notify ONLY other group members (outbid — red animation)
        await Clients.OthersInGroup(groupName).NotifyOutbid(bidInfo.AuctionId);

        // Confirm to the current bidder
        await Clients.Caller.BidConfirmed(bidInfo);
    }
}

Important group properties:

  • Groups are dynamic and in-memory — no configuration needed
  • A group exists as long as it has a member
  • On disconnection, the ConnectionId is automatically removed from all its groups
  • Groups are tied to connections (ConnectionId), not to users

6.2 Full Clients Target Options

// All options available in a Hub<IAuctionHubClient>
await Clients.All.ReceiveNewBid(bidInfo);                           // Everyone
await Clients.Others.ReceiveNewBid(bidInfo);                        // Everyone except caller
await Clients.Caller.BidConfirmed(bidInfo);                         // Caller only
await Clients.Client("connectionId").ReceiveNewBid(bidInfo);        // One specific client
await Clients.Clients(listOfConnIds).ReceiveNewBid(bidInfo);        // Multiple connections
await Clients.Group("auction-1").NotifyOutbid(1);                   // One group
await Clients.Groups(["auction-1","auction-2"]).ReceiveNewBid(b);   // Multiple groups
await Clients.AllExcept(["connId1","connId2"]).ReceiveNewBid(b);    // Everyone except a list
await Clients.User("userId").ReceiveNewBid(bidInfo);                // All connections of a user
await Clients.Users(["user1","user2"]).ReceiveNewBid(bidInfo);      // Multiple users
await Clients.OthersInGroup("auction-1").NotifyOutbid(1);           // Group members except caller

6.3 IHubContext — SignalR Outside a Hub

IHubContext<T> allows sending messages from anywhere in the application.

// Minimal API endpoint with IHubContext
app.MapPost("/auction/{auctionId}/newbid", async (
    int auctionId,
    [FromQuery] decimal value,
    IAuctionRepository repository,
    IHubContext<AuctionHub, IAuctionHubClient> hubContext) =>
{
    if (value <= 0)
        return Results.BadRequest("Amount must be positive.");

    var auction = await repository.GetByIdAsync(auctionId);
    if (auction is null) return Results.NotFound();

    // Validate the new bid is higher
    if (value <= auction.CurrentBid)
        return Results.BadRequest("Bid must exceed the current offer.");

    // Persist first
    auction.CurrentBid = value;
    await repository.UpdateAsync(auction);

    // Notify AFTER persistence is confirmed
    await hubContext.Clients.All.ReceiveNewBid(new BidInfo(auctionId, (int)value));

    return Results.Ok(auction);
});
// Background Service with IHubContext
public class AuctionTimerService : BackgroundService
{
    private readonly IHubContext<AuctionHub, IAuctionHubClient> _hubContext;
    private readonly IServiceScopeFactory _scopeFactory;

    public AuctionTimerService(
        IHubContext<AuctionHub, IAuctionHubClient> hubContext,
        IServiceScopeFactory scopeFactory)
    {
        _hubContext = hubContext;
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var repo = scope.ServiceProvider.GetRequiredService<IAuctionRepository>();

            // Notify clients when an auction ends
            var expiredAuctions = await repo.GetExpiredAsync();
            foreach (var auction in expiredAuctions)
            {
                auction.IsClosed = true;
                await repo.UpdateAsync(auction);
                await _hubContext.Clients.All.ReceiveNewAuction(auction);
            }

            await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
        }
    }
}

6.4 MessagePack Protocol (Binary)

dotnet add package Microsoft.AspNetCore.SignalR.Protocols.MessagePack  # Server
npm install @microsoft/signalr-protocol-msgpack                         # JS Client
// Program.cs
builder.Services.AddSignalR().AddMessagePackProtocol();
// homeindex.js
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub")
    .withHubProtocol(new signalR.protocols.msgpack.MessagePackHubProtocol())
    .build();

When to use MessagePack? Only if you have large objects and measured performance problems. For most applications, JSON is sufficient.

6.5 Streaming with IAsyncEnumerable

// Hub — Streaming server → client
public class StreamingHub : Hub
{
    public async IAsyncEnumerable<int> Counter(
        CounterOptions options,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        for (int i = 0; i < options.CountTo; i++)
        {
            if (cancellationToken.IsCancellationRequested) yield break;

            yield return i;
            await Task.Delay(options.DelayMs, cancellationToken);
        }
    }
}

public record CounterOptions(int CountTo, int DelayMs);
// JavaScript Client — Consuming a stream
const stream = connection.stream("Counter", { countTo: 100, delayMs: 200 });
const subscription = stream.subscribe({
    next: (value) => { document.getElementById("progress").textContent = value; },
    complete: () => { console.log("Stream completed."); },
    error: (err) => { console.error("Stream error:", err); }
});

// Cancel the stream (triggers CancellationToken on the server)
// subscription.dispose();

6.6 Exceptions and Logging

public class AuctionHub : Hub
{
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        // HubException → message explicitly sent to client (validation)
        if (bidInfo.NewBid <= 0)
            throw new HubException("Bid amount must be positive.");

        // Standard exception → hidden from client ("An unexpected error occurred")
        // Always logged server-side

        await Clients.All.SendAsync("ReceiveNewBid", bidInfo);
    }
}
// Capture exceptions client-side
try {
    await connection.invoke("NotifyNewBid", bidInfo);
} catch (err) {
    // HubException: err.message contains the server message
    // Other exception: err.message = "An unexpected error occurred"
    console.error("SignalR error:", err.message);
    showError(err.message);
}
// Error configuration (dev only!)
builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
    options.MaximumReceiveMessageSize = 102_400; // 100 KB
});

7. JavaScript Client — Complete Guide

7.1 Installation

# Via npm (modern projects)
npm install @microsoft/signalr

# Via LibMan (Visual Studio — MVC projects)
# Right-click → Add → Client-Side Library
# Provider: unpkg, Library: @microsoft/signalr
# Destination: wwwroot/js/signalr/
<!-- In the view — order matters! -->
<script src="~/js/signalr/dist/browser/signalr.min.js"></script>
<script src="~/js/homeindex.js"></script>

7.2 Connection and Full Lifecycle Management

// homeindex.js — Complete connection configuration

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub", {
        // Optional options:
        // transport: signalR.HttpTransportType.WebSockets,
        // accessTokenFactory: () => getToken(),
    })
    .withAutomaticReconnect([0, 2000, 5000, 10000, 30000]) // Retry delays in ms
    .configureLogging(signalR.LogLevel.Information)
    .build();

// Lifecycle events
connection.onreconnecting((error) => {
    console.warn("Reconnecting...", error);
    document.getElementById("status").textContent = "Reconnecting...";
    document.getElementById("status").className = "status warning";
});

connection.onreconnected((connectionId) => {
    console.log("Reconnected! New ID:", connectionId);
    document.getElementById("status").textContent = "Connected";
    document.getElementById("status").className = "status connected";
    // IMPORTANT: refresh data (some messages may have been missed)
    refreshAuctionData();
});

connection.onclose((error) => {
    console.error("Connection permanently closed.", error);
    document.getElementById("status").textContent = "Disconnected";
    document.getElementById("status").className = "status disconnected";
});

// Start with manual retry
async function startConnection() {
    try {
        await connection.start();
        console.log("Connection established. ID:", connection.connectionId);
        document.getElementById("status").textContent = "Connected";
    } catch (err) {
        console.error("Connection failed:", err);
        setTimeout(startConnection, 5000); // Retry after 5s
    }
}

startConnection();

7.3 Listening to Server Calls

// React to new bids
connection.on("ReceiveNewBid", ({ auctionId, newBid }) => {
    document.getElementById(`bid-${auctionId}`).textContent = newBid;
    document.getElementById(`input-${auctionId}`).value = newBid + 1;

    // Yellow animation (new bid)
    const row = document.getElementById(`row-${auctionId}`);
    row.classList.add("highlight");
    setTimeout(() => row.classList.remove("highlight"), 1000);
});

// Outbid notification (red background)
connection.on("NotifyOutbid", (auctionId) => {
    document.getElementById(`row-${auctionId}`)?.classList.add("outbid");
});

// Bid confirmed
connection.on("BidConfirmed", ({ auctionId }) => {
    document.getElementById(`row-${auctionId}`)?.classList.remove("outbid");
});

// New auction added
connection.on("ReceiveNewAuction", (auction) => {
    const tbody = document.querySelector("table tbody");
    tbody.insertAdjacentHTML("beforeend", `
        <tr id="row-${auction.id}">
            <td>${auction.itemName}</td>
            <td id="bid-${auction.id}">${auction.currentBid}</td>
            <td>
                <input type="number" id="input-${auction.id}" value="${auction.currentBid + 1}" />
                <button onclick="submitBid(${auction.id})">Bid</button>
            </td>
        </tr>
    `);
});

// Initial data on connection
connection.on("InitialData", (auctions) => {
    console.log(`${auctions.length} auctions received on startup.`);
});

7.4 Calling Hub Methods

async function submitBid(auctionId) {
    const newBid = parseInt(document.getElementById(`input-${auctionId}`).value);

    // Remove red animation if we were outbid
    document.getElementById(`row-${auctionId}`)?.classList.remove("outbid");

    // 1. Always persist via REST API (independent of SignalR)
    const response = await fetch(`/auction/${auctionId}/newbid?value=${newBid}`, {
        method: "POST"
    });

    if (!response.ok) {
        alert("Error submitting bid.");
        return;
    }

    // 2. Notify via SignalR if connected
    if (connection.state === signalR.HubConnectionState.Connected) {
        try {
            // invoke — Waits for server confirmation (throws if error)
            await connection.invoke("NotifyNewBid", {
                auctionId: parseInt(auctionId),
                newBid: newBid
            });
        } catch (err) {
            // SignalR failed but bid is already persisted
            console.warn("SignalR notification failed:", err.message);
            location.reload(); // Fallback: reload to see up-to-date data
        }
    } else {
        // SignalR not connected — reload the page
        location.reload();
    }
}

// send — Fire-and-forget (does not throw on failure)
await connection.send("SendNotification", message);

// invoke<T> — Call with return value
const auction = await connection.invoke("GetAuction", auctionId);

7.5 Custom Reconnect Strategy

class ExponentialBackoffRetryPolicy {
    nextRetryDelayInMilliseconds(retryContext) {
        // Stop after 5 minutes
        if (retryContext.elapsedMilliseconds > 5 * 60 * 1000) return null;

        const baseDelay = Math.min(1000 * Math.pow(2, retryContext.previousRetryCount), 30000);
        const jitter = Math.random() * 1000;
        return baseDelay + jitter;
    }
}

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub")
    .withAutomaticReconnect(new ExponentialBackoffRetryPolicy())
    .build();

8. .NET Client — Complete Guide

8.1 Installation and Configuration

dotnet add package Microsoft.AspNetCore.SignalR.Client
var connection = new HubConnectionBuilder()
    .WithUrl("https://localhost:7150/auctionhub", options =>
    {
        options.Transports = HttpTransportType.WebSockets;

        // Dev only: ignore SSL errors
        options.HttpMessageHandlerFactory = _ => new HttpClientHandler
        {
            ServerCertificateCustomValidationCallback =
                HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
        };
    })
    .WithAutomaticReconnect()
    .ConfigureLogging(logging =>
    {
        logging.AddConsole();
        logging.SetMinimumLevel(LogLevel.Information);
    })
    .Build();

8.2 Listening to Messages and Events

// Subscribe to server methods
connection.On<BidInfo>("ReceiveNewBid", (bidInfo) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"New bid: #{bidInfo.AuctionId} → {bidInfo.NewBid:C}");
    Console.ResetColor();
});

connection.On<IEnumerable<Auction>>("InitialData", (auctions) =>
{
    Console.WriteLine($"Initial data: {auctions.Count()} auctions");
});

// Lifecycle events
connection.Reconnecting += (error) =>
{
    Console.WriteLine($"Reconnecting... {error?.Message}");
    return Task.CompletedTask;
};

connection.Reconnected += (connectionId) =>
{
    Console.WriteLine($"Reconnected! ID: {connectionId}");
    return Task.CompletedTask;
};

connection.Closed += (error) =>
{
    Console.WriteLine($"Connection closed: {error?.Message ?? "Normal"}");
    return Task.CompletedTask;
};

8.3 Hub Calls and Error Handling

// Start the connection
await connection.StartAsync();
Console.WriteLine($"Connected! ID: {connection.ConnectionId}");

try
{
    // InvokeAsync — Waits for server-side completion
    await connection.InvokeAsync("NotifyNewBid", new BidInfo(1, 150));

    // InvokeAsync<T> — With return value
    var auction = await connection.InvokeAsync<Auction>("GetAuction", auctionId);

    // SendAsync — Fire-and-forget (does not throw)
    await connection.SendAsync("NotifyNewBid", new BidInfo(1, 150));
}
catch (HubException ex)
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine($"Hub error: {ex.Message}");
    Console.ResetColor();
}
finally
{
    await connection.StopAsync();
    await connection.DisposeAsync();
}

8.4 .NET Client with JWT Token

// Create connection with token provider
var connection = new HubConnectionBuilder()
    .WithUrl("https://localhost:7150/auctionhub", options =>
    {
        // SignalR passes the token in the QueryString automatically
        options.AccessTokenProvider = async () =>
        {
            // Fetch or renew token from your auth service
            return await tokenService.GetAccessTokenAsync();
        };
    })
    .WithAutomaticReconnect()
    .Build();

8.5 Complete Console Application

// Program.cs — Complete console client
using Microsoft.AspNetCore.SignalR.Client;
using System.Net.Http.Json;

Console.WriteLine("=== SignalR Auction Console Client ===\n");

// 1. Fetch auctions via REST
var httpClient = new HttpClient { BaseAddress = new Uri("https://localhost:7150") };
var auctions = await httpClient.GetFromJsonAsync<List<Auction>>("/auctions") ?? [];

Console.WriteLine("Available auctions:");
foreach (var a in auctions)
    Console.WriteLine($"  [{a.Id}] {a.ItemName} — Current bid: {a.CurrentBid:C}");

// 2. Configure SignalR
var connection = new HubConnectionBuilder()
    .WithUrl("https://localhost:7150/auctionhub")
    .WithAutomaticReconnect()
    .Build();

connection.On<BidInfo>("ReceiveNewBid", (bidInfo) =>
{
    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine($"\n[LIVE] Auction #{bidInfo.AuctionId}: {bidInfo.NewBid:C}");
    Console.ResetColor();
});

connection.On<int>("NotifyOutbid", (auctionId) =>
{
    Console.ForegroundColor = ConsoleColor.Red;
    Console.WriteLine($"\n[OUTBID] You were outbid on item #{auctionId}!");
    Console.ResetColor();
});

// 3. Start
await connection.StartAsync();
Console.WriteLine("\nSignalR connection established.\n");

// 4. Enter a bid
Console.Write("Auction ID: ");
var auctionId = int.Parse(Console.ReadLine() ?? "1");
Console.Write("Amount: ");
var newBid = int.Parse(Console.ReadLine() ?? "100");

var response = await httpClient.PostAsync($"/auction/{auctionId}/newbid?value={newBid}", null);
if (response.IsSuccessStatusCode)
{
    await connection.InvokeAsync("NotifyNewBid", new BidInfo(auctionId, newBid));
    Console.WriteLine("Bid placed!");
}

Console.WriteLine("Press Enter to quit...");
Console.ReadLine();
await connection.StopAsync();

record BidInfo(int AuctionId, int NewBid);
record Auction(int Id, string ItemName, decimal CurrentBid, bool IsClosed);

9. Authentication and Authorization

// Program.cs
builder.Services.AddDefaultIdentity<IdentityUser>(options =>
{
    options.Password.RequireDigit = true;
    options.Password.RequiredLength = 8;
})
.AddEntityFrameworkStores<AppDbContext>();

builder.Services.AddSignalR();

var app = builder.Build();

app.UseAuthentication(); // BEFORE UseAuthorization
app.UseAuthorization();

app.MapHub<AuctionHub>("/auctionhub");
app.MapRazorPages(); // Identity pages (login, register...)
// Hub protected by [Authorize]
[Authorize]
public class AuctionHub : Hub
{
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        var username = Context.User?.Identity?.Name;
        var userId = Context.UserIdentifier;

        await Clients.All.SendAsync("ReceiveNewBid", bidInfo);
    }
}

9.2 JWT Token Authentication (Bearer)

Why not headers? WebSockets do not support custom HTTP headers. The JWT token must be passed in the QueryString.

sequenceDiagram
    participant C as Client
    participant S as SignalR Server

    Note over C,S: Negotiation — token in HTTP header
    C->>S: POST /auctionhub/negotiate\nAuthorization: Bearer eyJhbG...
    S-->>C: 200 {"connectionId":"abc"}

    Note over C,S: WebSocket — cannot send headers
    C->>S: GET /auctionhub?id=abc&access_token=eyJhbG...\n[Upgrade: websocket]
    S-->>C: 101 Switching Protocols
    Note over C,S: SignalR JS automatically handles the QueryString
// Program.cs — JWT Bearer for SignalR
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://demo.duendesoftware.com";
        options.Audience = "api";

        // CRUCIAL: read token from QueryString for WebSockets
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var accessToken = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;

                if (!string.IsNullOrEmpty(accessToken) &&
                    path.StartsWithSegments("/auctionhub"))
                {
                    context.Token = accessToken;
                }
                return Task.CompletedTask;
            }
        };
    });
// JavaScript Client — Provide JWT token
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub", {
        accessTokenFactory: () => {
            return localStorage.getItem("access_token"); // or from an API
        }
    })
    .withAutomaticReconnect()
    .build();
// .NET Client — Provide token
var connection = new HubConnectionBuilder()
    .WithUrl("https://localhost:7150/auctionhub", options =>
    {
        options.AccessTokenProvider = async () =>
            await tokenService.GetAccessTokenAsync();
    })
    .Build();

9.3 BFF Pattern (Backend for Frontend)

The BFF allows browsers to use tokens without exposing them to JavaScript (XSS protection).

graph TD
    Browser["Browser\n(JavaScript)"] -->|"1. Access without cookie"| BFF["ASP.NET Core App\n(BFF Server)"]
    BFF -->|"2. Redirect to login"| IdP["Identity Provider\n(Duende / Azure AD / Auth0)"]
    IdP -->|"3. User credentials"| IdP
    IdP -->|"4. identity_token + access_token"| BFF
    BFF -->|"5. Stores access_token\nserver-side ONLY"| BFF
    BFF -->|"6. Set-Cookie (encrypted)"| Browser
    Browser -->|"7. All requests with cookie"| BFF
    BFF -->|"8. Uses access_token\nfor external APIs"| ExternalAPI["External API"]

    style IdP fill:#FF9800,color:white
    style BFF fill:#2196F3,color:white
// Program.cs — BFF with Duende.BFF
// dotnet add package Duende.BFF

builder.Services.AddBff();
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = "cookie";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
    options.Cookie.Name = "__Host-bff";
    options.Cookie.SameSite = SameSiteMode.Strict;
})
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "https://demo.duendesoftware.com";
    options.ClientId = "interactive.confidential";
    options.ClientSecret = "secret";
    options.Scope.Add("openid profile api");
    options.SaveTokens = true;
});

app.UseAuthentication();
app.UseAuthorization();
app.UseBff();
app.MapBffManagementEndpoints(); // /bff/login, /bff/logout, /bff/user

app.MapHub<AuctionHub>("/auctionhub")
    .RequireAuthorization()
    .AsBffApiEndpoint();

9.4 Policy-Based Authorization

// Program.cs — Define policies
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AdminOnly", policy =>
        policy.RequireClaim(ClaimTypes.Role, "admin"));
});

// Hub with granular authorization
[Authorize] // Entire Hub requires authentication
public class AuctionHub : Hub<IAuctionHubClient>
{
    [Authorize(Policy = "AdminOnly")]
    public async Task CloseAuction(int auctionId)
    {
        // Only admins can close an auction
        await Clients.All.ReceiveNewAuction(null!);
    }

    // Method accessible to all authenticated users
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        await Clients.All.ReceiveNewBid(bidInfo);
    }
}

10. Hosting and Scaling

10.1 Deployment Checklist

graph TD
    Deploy["SignalR Deployment"] --> WS["✅ WebSockets enabled\non server / cloud"]
    Deploy --> HTTPS["✅ HTTPS mandatory\n(WSS instead of WS)"]
    Deploy --> Limits["✅ Connection limits\nverified"]
    Deploy --> Sticky["✅ Sticky Sessions (ARR Affinity)\nif non-WebSocket transports"]
    Deploy --> Scale{"Multiple instances?"}
    Scale -->|Yes| Redis["Redis Backplane"]
    Scale -->|Yes Cloud| Azure["Azure SignalR Service\n(recommended)"]
    Scale -->|No| Done["✅ Ready"]
    Redis --> Done
    Azure --> Done

    style Deploy fill:#2196F3,color:white
    style Done fill:#4CAF50,color:white

Azure App Service WebSocket connection limits:

PlanMax WebSocket connections
Free / SharedNot supported
Basic B15
Standard S2350
Premium P2v33500

10.2 Scale-Out Problem Without Backplane

graph TD
    LB["Load Balancer"] -->|"Client 1, 2"| I1["Instance 1\nConnections: 1,2"]
    LB -->|"Client 3, 4"| I2["Instance 2\nConnections: 3,4"]

    I1 -->|"Client1 calls NotifyNewBid\nInstance 1 sends to Client1 and Client2"| OK["✅ Client1, 2 receive"]
    I1 -.->|"Instance 2 receives nothing!"| Err["❌ Client3, 4 receive nothing"]

    style Err fill:#F44336,color:white
    style OK fill:#4CAF50,color:white

10.3 Redis Backplane

dotnet add package Microsoft.AspNetCore.SignalR.StackExchangeRedis
// Program.cs — Redis Backplane
var redisConnectionString = builder.Configuration["Redis:ConnectionString"];

builder.Services.AddSignalR()
    .AddStackExchangeRedis(redisConnectionString!, options =>
    {
        options.Configuration.ChannelPrefix = "MyApp:SignalR";
    });

// SignalR uses ONLY Redis Pub/Sub — no data is stored there
graph TD
    LB["Load Balancer"] -->|"Client 1, 2"| I1["Instance 1"]
    LB -->|"Client 3, 4"| I2["Instance 2"]

    I1 <-->|"Pub/Sub"| Redis["Redis\nBackplane"]
    I2 <-->|"Pub/Sub"| Redis

    I1 -->|"1. Publishes message"| Redis
    Redis -->|"2. Instance 2 receives"| I2
    I2 -->|"3. Sends to Client3, 4"| I2

    style Redis fill:#D32F2F,color:white
    style I1 fill:#4CAF50,color:white
    style I2 fill:#4CAF50,color:white

10.4 Azure SignalR Service

dotnet add package Microsoft.Azure.SignalR
// Program.cs
builder.Services.AddSignalR()
    .AddAzureSignalR(builder.Configuration["AzureSignalR:ConnectionString"]);

// appsettings.json:
// "AzureSignalR": {
//   "ConnectionString": "Endpoint=https://...;AccessKey=...;Version=1.0;"
// }
graph TD
    Clients["Clients\n(browsers, apps)"] -->|"WebSocket connections"| AzSR["Azure SignalR Service\n(manages all connections)"]
    AzSR <-->|"Persistent tunnel"| AppI1["Your App\nInstance 1"]
    AzSR <-->|"Persistent tunnel"| AppI2["Your App\nInstance 2"]

    style AzSR fill:#0078D4,color:white
    style AppI1 fill:#4CAF50,color:white
    style AppI2 fill:#4CAF50,color:white

10.5 Scale-Out Solutions Comparison

SolutionComplexityCostRecommended for
Single instanceNoneFreeSmall apps
Sticky SessionsLowFreeNon-WebSocket, few instances
Redis BackplaneMedium~$15/monthOn-premise, multi-cloud
Azure SignalR ServiceLowVariableAzure cloud

11. Performance and Advanced Configuration

11.1 Server Configuration

builder.Services.AddSignalR(options =>
{
    // KeepAlive ping interval (default: 15s)
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);

    // Delay before considering client disconnected (default: 30s)
    // Must be >= KeepAliveInterval * 2
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);

    // Max size of a received message (default: 32 KB)
    options.MaximumReceiveMessageSize = 65_536; // 64 KB

    // Buffer size per connection
    options.StreamBufferCapacity = 10;

    // Detailed errors (ONLY in development!)
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();

    // Parallel invocations per connection (default: 1)
    options.MaximumParallelInvocationsPerClient = 1;
});

11.2 CORS with SignalR

// CORS required if client is on a different domain
builder.Services.AddCors(options =>
{
    options.AddPolicy("SignalRPolicy", policy =>
    {
        policy
            .WithOrigins("https://my-frontend.com", "https://localhost:3000")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials(); // REQUIRED for SignalR
            // Cannot be combined with AllowAnyOrigin()
    });
});

app.UseCors("SignalRPolicy"); // Before MapHub!
app.MapHub<AuctionHub>("/auctionhub");

11.3 Robust Architecture — Graceful Fallback

graph TD
    subgraph "Recommended Architecture"
        User["User"] --> JS["JavaScript (Browser)"]
        JS -->|"1. POST /api/bid\n(persist — always)"| RestAPI["REST API\n(Data Processing)"]
        JS -->|"2. invoke(NotifyNewBid)\n(if connected)"| Hub["SignalR Hub\n(Notifications only)"]

        RestAPI -->|"Data persisted"| DB["Database"]
        Hub -->|"Notifications"| AllClients["All connected clients"]

        JS -->|"If SignalR unavailable"| Fallback["location.reload()\n(at least data is saved)"]
    end

    style Hub fill:#2196F3,color:white
    style RestAPI fill:#4CAF50,color:white

11.4 Configuration Limits Summary

ParameterDefaultRecommendation
MaximumReceiveMessageSize32 KBIncrease if needed, not too much (DoS risk)
KeepAliveInterval15s10-15s recommended
ClientTimeoutInterval30sMinimum 2× KeepAliveInterval
MaximumParallelInvocationsPerClient1Increase with caution
ProtocolJSONMessagePack if large objects

12. Demo Application — Full Auction System

12.1 Project Structure

AuctionApp/
├── Controllers/
│   └── HomeController.cs
├── Hubs/
│   ├── AuctionHub.cs
│   └── IAuctionHubClient.cs
├── Models/
│   ├── Auction.cs
│   └── BidInfo.cs
├── Repositories/
│   ├── IAuctionRepository.cs
│   └── InMemoryAuctionRepository.cs
├── Views/
│   └── Home/
│       └── Index.cshtml
├── wwwroot/
│   └── js/
│       ├── signalr/
│       └── homeindex.js
└── Program.cs

12.2 Models

// Models/Auction.cs
public class Auction
{
    public int Id { get; set; }
    public string ItemName { get; set; } = string.Empty;
    public decimal CurrentBid { get; set; }
    public decimal MinimumBid { get; set; }
    public bool IsClosed { get; set; }
}

// Models/BidInfo.cs
public record BidInfo(int AuctionId, int NewBid);

12.3 Repository

// IAuctionRepository.cs
public interface IAuctionRepository
{
    IEnumerable<Auction> GetAll();
    Auction? GetById(int id);
    void Add(Auction auction);
    void Update(Auction auction);
}

// InMemoryAuctionRepository.cs
public class InMemoryAuctionRepository : IAuctionRepository
{
    private readonly List<Auction> _auctions =
    [
        new() { Id = 1, ItemName = "Antique vase", CurrentBid = 100 },
        new() { Id = 2, ItemName = "Impressionist painting", CurrentBid = 500 },
        new() { Id = 3, ItemName = "Collector's watch", CurrentBid = 250 },
    ];

    public IEnumerable<Auction> GetAll() => _auctions.AsReadOnly();
    public Auction? GetById(int id) => _auctions.FirstOrDefault(a => a.Id == id);

    public void Add(Auction auction)
    {
        auction.Id = _auctions.Count > 0 ? _auctions.Max(a => a.Id) + 1 : 1;
        _auctions.Add(auction);
    }

    public void Update(Auction auction)
    {
        var index = _auctions.FindIndex(a => a.Id == auction.Id);
        if (index >= 0) _auctions[index] = auction;
    }
}

12.4 Typed Client Interface

// Hubs/IAuctionHubClient.cs
public interface IAuctionHubClient
{
    Task ReceiveNewBid(BidInfo bidInfo);
    Task ReceiveNewAuction(Auction auction);
    Task NotifyOutbid(int auctionId);
    Task BidConfirmed(BidInfo bidInfo);
    Task InitialData(IEnumerable<Auction> auctions);
}

12.5 Complete Hub

// Hubs/AuctionHub.cs
[Authorize] // Uncomment to enable auth
public class AuctionHub : Hub<IAuctionHubClient>
{
    private readonly IAuctionRepository _repository;
    private readonly ILogger<AuctionHub> _logger;

    public AuctionHub(IAuctionRepository repository, ILogger<AuctionHub> logger)
    {
        _repository = repository;
        _logger = logger;
    }

    public override async Task OnConnectedAsync()
    {
        _logger.LogInformation("Connected: {Id}", Context.ConnectionId);
        await Clients.Caller.InitialData(_repository.GetAll());
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        _logger.LogInformation("Disconnected: {Id}, Reason: {Reason}",
            Context.ConnectionId, exception?.Message ?? "Normal");
        await base.OnDisconnectedAsync(exception);
    }

    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        var groupName = $"auction-{bidInfo.AuctionId}";

        await Groups.AddToGroupAsync(Context.ConnectionId, groupName);

        // All see the new bid (yellow animation)
        await Clients.All.ReceiveNewBid(bidInfo);

        // Other bidders on this item are notified (outbid — red animation)
        await Clients.OthersInGroup(groupName).NotifyOutbid(bidInfo.AuctionId);

        // Confirm to current bidder
        await Clients.Caller.BidConfirmed(bidInfo);
    }
}

12.6 Complete Program.cs

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddSingleton<IAuctionRepository, InMemoryAuctionRepository>();
builder.Services.AddSignalR(o => o.EnableDetailedErrors = builder.Environment.IsDevelopment());

var app = builder.Build();

if (!app.Environment.IsDevelopment()) { app.UseHsts(); }
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();

// REST endpoints
app.MapGet("/auctions", (IAuctionRepository repo) => repo.GetAll());

app.MapPost("/auction/{auctionId}/newbid", (
    int auctionId, [FromQuery] decimal value, IAuctionRepository repo) =>
{
    var auction = repo.GetById(auctionId);
    if (auction is null) return Results.NotFound();
    auction.CurrentBid = value;
    repo.Update(auction);
    return Results.Ok(auction);
});

app.MapPost("/auction", async (
    Auction auction,
    IAuctionRepository repo,
    IHubContext<AuctionHub, IAuctionHubClient> hubContext) =>
{
    repo.Add(auction);
    await hubContext.Clients.All.ReceiveNewAuction(auction);
    return Results.Created($"/auction/{auction.Id}", auction);
});

app.MapDefaultControllerRoute();
app.MapHub<AuctionHub>("/auctionhub");

app.Run();

12.7 Complete JavaScript (homeindex.js)

// wwwroot/js/homeindex.js

const connection = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub")
    .withAutomaticReconnect()
    .build();

// === Server → client events ===

connection.on("ReceiveNewBid", ({ auctionId, newBid }) => {
    document.getElementById(`bid-${auctionId}`).textContent = newBid;
    document.getElementById(`input-${auctionId}`).value = newBid + 1;

    const row = document.getElementById(`row-${auctionId}`);
    row.classList.add("highlight");
    setTimeout(() => row.classList.remove("highlight"), 1000);
});

connection.on("NotifyOutbid", (auctionId) => {
    document.getElementById(`row-${auctionId}`)?.classList.add("outbid");
});

connection.on("BidConfirmed", ({ auctionId }) => {
    document.getElementById(`row-${auctionId}`)?.classList.remove("outbid");
});

connection.on("ReceiveNewAuction", (auction) => {
    const tbody = document.querySelector("table tbody");
    tbody.insertAdjacentHTML("beforeend", `
        <tr id="row-${auction.id}">
            <td>${auction.itemName}</td>
            <td id="bid-${auction.id}">${auction.currentBid}</td>
            <td>
                <input type="number" id="input-${auction.id}" value="${auction.currentBid + 1}" />
                <button onclick="submitBid(${auction.id})">Bid</button>
            </td>
        </tr>`);
});

// === Connection management ===

connection.onreconnecting(() => {
    document.getElementById("status").textContent = "Reconnecting...";
});

connection.onreconnected(() => {
    document.getElementById("status").textContent = "Connected";
});

connection.onclose(() => {
    document.getElementById("status").textContent = "Disconnected — reload the page";
});

async function startConnection() {
    try {
        await connection.start();
        document.getElementById("status").textContent = "Connected";
    } catch (err) {
        console.error("Connection failed:", err);
        setTimeout(startConnection, 5000);
    }
}

startConnection();

// === User actions ===

async function submitBid(auctionId) {
    const newBid = parseInt(document.getElementById(`input-${auctionId}`).value);

    // 1. Persist via API (always)
    const res = await fetch(`/auction/${auctionId}/newbid?value=${newBid}`, {
        method: "POST"
    });

    if (!res.ok) { alert("Bid error."); return; }

    // 2. Notify via SignalR if available
    if (connection.state === signalR.HubConnectionState.Connected) {
        try {
            await connection.invoke("NotifyNewBid", {
                auctionId: parseInt(auctionId),
                newBid: newBid
            });
        } catch (err) {
            console.warn("SignalR failed:", err.message);
            location.reload();
        }
    } else {
        location.reload(); // Fallback
    }
}

12.8 Index.cshtml View

@model IEnumerable<Auction>
@{ ViewData["Title"] = "Real-Time Auctions"; }

<h2>Real-Time Auctions</h2>
<div id="status">Connecting...</div>

<table>
    <thead>
        <tr><th>Item</th><th>Current bid</th><th>Action</th></tr>
    </thead>
    <tbody>
        @foreach (var auction in Model)
        {
            <tr id="row-@auction.Id">
                <td>@auction.ItemName</td>
                <td id="bid-@auction.Id">@auction.CurrentBid</td>
                <td>
                    <input type="number" id="input-@auction.Id"
                           value="@(auction.CurrentBid + 1)" />
                    <button onclick="submitBid(@auction.Id)">Bid</button>
                </td>
            </tr>
        }
    </tbody>
</table>

@section Scripts {
    <script src="~/js/signalr/dist/browser/signalr.min.js"></script>
    <script src="~/js/homeindex.js"></script>
}

13. Summary and Best Practices

13.1 Golden Rule — Decoupled Architecture

Design your application to work EVEN WITHOUT SignalR. The Hub connection is less reliable than a classic REST API.

graph LR
    subgraph "✅ Correct Architecture"
        API1["REST API\n(handles data)"] --> DB1["DB"]
        Hub1["SignalR Hub\n(notifies only)"] --> Clients1["Clients"]
        DB1 -.->|"After success"| Hub1
    end

    subgraph "❌ Incorrect Architecture"
        Hub2["SignalR Hub\n(persists AND notifies)"] --> DB2["DB"]
        Hub2 --> Clients2["Clients"]
    end

What the Hub SHOULD do:

  • Send real-time notifications
  • Manage client groups
  • Relay events after persistence

What the Hub should NOT do:

  • Persist data (use a REST API)
  • Be the only way to update the UI
  • Contain critical business logic

13.2 Common Pitfalls

PitfallProblemSolution
Magic stringsSilent typosUse Hub<T> with interface
Unstable ConnectionIdChanges on every reconnectionUse UserIdentifier
Business logic in HubTight coupling, hard to testREST API + Hub for notifications
Scale-out without backplaneLost messagesRedis or Azure SignalR Service
Token in WS headerWebSockets without headersQueryString access_token
CORS without AllowCredentialsConnection refused.AllowCredentials()
EnableDetailedErrors in prodSensitive info leakfalse in production
No fallbackApp unusable if SignalR goes downAlways REST first

13.3 Security Checklist

  • HTTPS/WSS — Always in production
  • AuthenticationOnMessageReceived for JWT via QueryString
  • Authorization[Authorize] on Hub and/or sensitive methods
  • CORSAllowCredentials() with explicit origins (not AllowAnyOrigin)
  • Validation — Validate all parameters in Hub methods
  • MaximumReceiveMessageSize — Limit to prevent DoS attacks
  • EnableDetailedErrorsfalse in production
  • Connection strings — Use appsettings / Key Vault, never hardcoded

13.4 Library Reference

TechnologyPackageNotes
JavaScript Browser@microsoft/signalrnpm or LibMan
.NET (Console, WPF, MAUI)Microsoft.AspNetCore.SignalR.ClientNuGet
Java / Androidcom.microsoft.signalrMaven
BlazorBuilt-inSimilar API to .NET client
MessagePack ServerMicrosoft.AspNetCore.SignalR.Protocols.MessagePackOptional
MessagePack JS Client@microsoft/signalr-protocol-msgpacknpm
Redis BackplaneMicrosoft.AspNetCore.SignalR.StackExchangeRedisScale-out
Azure SignalR ServiceMicrosoft.Azure.SignalRCloud scale-out
BFFDuende.BFFBrowser auth with tokens

14. Review Questions

Q1 — Transports

Question: What are the three transports supported by SignalR? In what order are they tried? Why is WebSockets preferred?

Answer: SignalR supports WebSockets (tried first), Server-Sent Events (fallback 1) and Long Polling (fallback 2). WebSockets is preferred because it is the only full duplex transport (simultaneous traffic in both directions) over a single persistent TCP connection, with no HTTP overhead per message.


Q2 — Sticky Sessions

Question: Why are sticky sessions (ARR Affinity) necessary with certain transports in a scale-out environment?

Answer: With SSE and Long Polling, each message is a separate HTTP request that may arrive at a different server behind a load balancer. Sticky sessions ensure that all requests from the same client arrive at the same server, which holds the connection context. With pure WebSockets, this is not necessary because a single persistent TCP connection is established with one server.


Q3 — IHubContext

Question: When should you use IHubContext<T> rather than calling Clients.All directly in a Hub?

Answer: IHubContext<T> should be used when sending messages is triggered outside of a Hub method — from a controller, a service, a background job, or a Minimal API. The main advantage: you can send notifications after confirming data persistence, ensuring consistency.


Q4 — ConnectionId vs UserIdentifier

Question: What is the difference between Context.ConnectionId and Context.UserIdentifier? Which one is stable after reconnection?

Answer: Context.ConnectionId is unique per physical connection and changes on every reconnection. Context.UserIdentifier is based on the user’s NameIdentifier claim and remains constant even after reconnection. To identify a user in a stable way, use UserIdentifier.


Q5 — JWT Bearer and WebSockets

Question: Why do you need to configure OnMessageReceived for JWT authentication with SignalR?

Answer: WebSockets do not support custom HTTP headers after the connection upgrade. The JWT token must therefore be transmitted in the QueryString (?access_token=...). The ASP.NET Core JWT Bearer middleware only reads headers by default. OnMessageReceived allows copying the token from the QueryString to the authentication context.


Q6 — Strongly Typed Hub

Question: What are the advantages of using Hub<IAuctionHubClient> compared to Hub with SendAsync("MethodName", ...)?

Answer: Strongly typed hubs offer: compile-time checking (errors appear before runtime), IntelliSense in the IDE, automatic refactoring (renaming a method in the interface updates all usages), and no magic strings susceptible to silent typos.


Q7 — Redis Backplane

Question: Does SignalR store data in Redis? What Redis mechanism does it use?

Answer: No, SignalR stores no data in Redis. It only uses the Redis Pub/Sub (publish/subscribe) mechanism to allow instances to communicate. When instance 1 needs to send to a client on instance 2, it publishes to Redis; instance 2 is subscribed and receives the message.


Q8 — Groups and Disconnection

Question: Do you need to manually clean up SignalR groups when a client disconnects?

Answer: No. SignalR automatically cleans up groups on disconnection. The ConnectionId is removed from all its groups. Groups themselves are in-memory and do not need to be declared or configured — they exist as long as they have at least one member.


Q9 — KeepAlive

Question: What purpose do KeepAlive messages (type 6) serve? How do you configure the intervals?

Answer: Proxies and routers automatically close idle TCP connections. KeepAlive messages keep the connection alive. If the server receives no response within ClientTimeoutInterval (30s by default), it considers the client disconnected. Rule: ClientTimeoutInterval >= 2 × KeepAliveInterval.


Q10 — HubException

Question: What is the difference between throwing a HubException and a standard exception from a Hub?

Answer: A standard exception (e.g., InvalidOperationException) has its message hidden from the client (security) — the client receives "An unexpected error occurred". A HubException sends its message explicitly to the client, used for intentional validation errors.


Q11 — Azure SignalR Service vs Redis

Question: When should you choose Azure SignalR Service over Redis Backplane?

Answer: Azure SignalR Service is recommended if you are on Azure and want maximum simplicity (no sticky sessions, no Redis to manage, automatic scaling). Redis Backplane is preferable for on-premise deployments, multi-cloud, or if Redis is already in place in your infrastructure.


Q12 — OnConnectedAsync

Question: What happens if you forget to call await base.OnConnectedAsync() in an OnConnectedAsync override?

Answer: The connection may not be properly registered in the Hub. The base call is necessary for SignalR to perform its internal initialization (connection registration, etc.). Always call await base.OnConnectedAsync() and await base.OnDisconnectedAsync(exception) in overrides.


15. Streaming with SignalR

15.1 What is Streaming in SignalR?

Streaming allows a Hub to send a continuous stream of data to the client, element by element, rather than a single response. The client begins processing data from the first element — without waiting for the operation to complete.

sequenceDiagram
    participant C as Client (JS / .NET)
    participant H as AuctionHub

    C->>H: invoke("Counter", { countTo: 10, delayMs: 500 })
    Note over H: Starts generating
    H-->>C: stream item: 0
    H-->>C: stream item: 1
    H-->>C: stream item: 2
    Note over C: Processes each value as it arrives
    H-->>C: stream item: ...
    H-->>C: stream item: 9
    H-->>C: stream complete

Use cases:

  • Progress bars for long tasks (import, export, calculation)
  • Real-time log display
  • Progressively returned search results
  • IoT sensor data at regular intervals
  • Large dataset export without saturating memory

15.2 Server → Client Streaming with IAsyncEnumerable

// Hubs/StreamingHub.cs
using System.Runtime.CompilerServices;

public class StreamingHub : Hub
{
    // Streaming method — returns IAsyncEnumerable<T>
    public async IAsyncEnumerable<int> Counter(
        int countTo,
        int delayMs,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        for (int i = 0; i < countTo; i++)
        {
            // Check if client cancelled the stream
            cancellationToken.ThrowIfCancellationRequested();

            yield return i;

            // Wait before the next item
            await Task.Delay(delayMs, cancellationToken);
        }
    }

    // Streaming complex objects
    public async IAsyncEnumerable<AuctionEvent> GetAuctionHistory(
        int auctionId,
        [EnumeratorCancellation] CancellationToken cancellationToken)
    {
        var events = GetEventsFromDatabase(auctionId);

        await foreach (var evt in events.WithCancellation(cancellationToken))
        {
            yield return evt;
            await Task.Delay(50, cancellationToken); // Throttle
        }
    }

    private async IAsyncEnumerable<AuctionEvent> GetEventsFromDatabase(int auctionId)
    {
        // Simulate an async data source
        for (int i = 1; i <= 20; i++)
        {
            yield return new AuctionEvent(auctionId, i * 10, DateTime.UtcNow.AddMinutes(-i));
            await Task.Delay(10);
        }
    }
}

public record AuctionEvent(int AuctionId, decimal BidAmount, DateTime Timestamp);

15.3 JavaScript Client — Consuming a Stream

// Method 1: subscribe API (recommended)
async function startCounterStream() {
    const countTo = 100;
    const progressBar = document.getElementById("progress");
    const label = document.getElementById("progress-label");

    const stream = connection.stream("Counter", countTo, 200);

    const subscription = stream.subscribe({
        next: (value) => {
            const pct = Math.round((value / countTo) * 100);
            progressBar.style.width = `${pct}%`;
            label.textContent = `${value} / ${countTo}`;
        },
        complete: () => {
            label.textContent = "Done!";
            console.log("Stream completed.");
        },
        error: (err) => {
            console.error("Stream error:", err);
            label.textContent = "Error.";
        }
    });

    // Cancel after 10 seconds if not done
    setTimeout(() => subscription.dispose(), 10_000);
}

// Method 2: Async generator (ES2018+)
async function startCounterStreamAsync() {
    try {
        for await (const value of connection.stream("Counter", 50, 100)) {
            document.getElementById("counter").textContent = value;
        }
        console.log("Stream completed.");
    } catch (err) {
        if (err.name === "AbortError") {
            console.log("Stream cancelled.");
        } else {
            console.error("Error:", err);
        }
    }
}

15.4 .NET Client — Consuming a Stream

// Console client — Consuming a stream with IAsyncEnumerable
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));

await foreach (var value in connection.StreamAsync<int>(
    "Counter", 100, 100, cancellationToken: cts.Token))
{
    Console.Write($"\rProgress: {value:D3} / 100");
}

Console.WriteLine("\nStream completed.");

// Consuming a stream of objects
await foreach (var evt in connection.StreamAsync<AuctionEvent>(
    "GetAuctionHistory", 42, cancellationToken: cts.Token))
{
    Console.WriteLine($"[{evt.Timestamp:HH:mm:ss}] Auction #{evt.AuctionId}: {evt.BidAmount:C}");
}

15.5 Client → Server Streaming

// Hub — Receive a stream from the client
public async Task UploadAuctionImages(
    IAsyncEnumerable<ImageChunk> chunks,
    int auctionId,
    CancellationToken cancellationToken)
{
    var allChunks = new List<byte>();

    await foreach (var chunk in chunks.WithCancellation(cancellationToken))
    {
        allChunks.AddRange(chunk.Data);
        // Notify progress
        await Clients.Caller.SendAsync("UploadProgress",
            allChunks.Count, cancellationToken: cancellationToken);
    }

    // Process the complete file
    await SaveImageAsync(auctionId, allChunks.ToArray());
    await Clients.Caller.SendAsync("UploadComplete", auctionId);
}

public record ImageChunk(byte[] Data, int ChunkIndex);
// JS Client — Send a stream to the server
async function uploadImage(file, auctionId) {
    const CHUNK_SIZE = 32 * 1024; // 32 KB per chunk
    const subject = new signalR.Subject();

    // Start the upload (don't await here — stream in parallel)
    connection.send("UploadAuctionImages", subject, auctionId);

    // Read file in chunks and send
    let offset = 0;

    while (offset < file.size) {
        const blob = file.slice(offset, offset + CHUNK_SIZE);
        const buffer = await blob.arrayBuffer();
        subject.next({
            data: Array.from(new Uint8Array(buffer)),
            chunkIndex: Math.floor(offset / CHUNK_SIZE)
        });
        offset += CHUNK_SIZE;
    }

    subject.complete(); // Signal end of stream
}

16. Connection Lifecycle — Complete Guide

16.1 Complete State Machine

stateDiagram-v2
    direction LR
    [*] --> Disconnected : Initialization

    Disconnected --> Connecting : connection.start()
    Connecting --> Connected : Success\n→ OnConnectedAsync()
    Connecting --> Disconnected : Definitive failure

    Connected --> Reconnecting : Network loss detected\n→ onreconnecting callback
    Connected --> Disconnected : connection.stop()\n→ OnDisconnectedAsync(null)
    Connected --> Disconnected : ServerTimeout exceeded\n→ OnDisconnectedAsync(exception)

    Reconnecting --> Connected : Reconnection succeeded\n→ NEW ConnectionId\n→ OnConnectedAsync()\n→ onreconnected callback
    Reconnecting --> Disconnected : All attempts exhausted\n→ OnDisconnectedAsync(exception)\n→ onclose callback

Critical point: Each reconnection generates a new ConnectionId. The application must be designed to handle this (re-join groups, refresh state).

16.2 Advanced Reconnect Configuration

// homeindex.js — Auto reconnect with custom strategy

// Option 1: Fixed delays (ms)
const connection = new signalR.HubConnectionBuilder()
    .withUrl("/auctionhub")
    .withAutomaticReconnect([0, 1000, 5000, 10000, 30000, 60000])
    .build();

// Option 2: Custom exponential backoff strategy
class SmartRetryPolicy {
    nextRetryDelayInMilliseconds(retryContext) {
        const elapsed = retryContext.elapsedMilliseconds;
        const retryCount = retryContext.previousRetryCount;

        // Stop after 5 minutes
        if (elapsed > 5 * 60 * 1000) {
            console.error("Could not reconnect after 5 minutes.");
            return null; // null = stop
        }

        // Exponential backoff with jitter (avoids reconnection storms)
        const base = Math.min(1000 * Math.pow(2, retryCount), 30_000);
        const jitter = Math.random() * 1000;
        return base + jitter;
    }
}

17. Strongly Typed Hubs — Complete Guide

17.1 The Magic Strings Problem — Illustrated

// ❌ Untyped Hub — multiple risks
public class WeakHub : Hub
{
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        // Silent typo — no compile error!
        await Clients.All.SendAsync("RecieveNewBid", bidInfo); // "Recieve" instead of "Receive"

        // Wrong argument type — error only at runtime
        await Clients.Caller.SendAsync("BidConfirmed", "wrong-type-here");
    }
}

// ✅ Typed Hub — errors detected at compile time
public class StrongHub : Hub<IAuctionHubClient>
{
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        // Compile error if name is wrong
        await Clients.All.ReceiveNewBid(bidInfo); // Full IntelliSense!

        // Compile error if wrong type is passed
        await Clients.Caller.BidConfirmed(bidInfo); // Must be BidInfo
    }
}

17.2 Typed vs Untyped Hub Comparison

AspectHub (untyped)Hub<T> (typed)
Method nameManual string "ReceiveNewBid"Compiled method .ReceiveNewBid(...)
VerificationRuntime (too late)Compile time
IntelliSenseNoneComplete
RefactoringBrokenAutomatic
Type safetyWeakStrong
Initial complexityLowSlightly higher
RecommendedRapid prototypingProduction

18. Advanced Configuration and Performance

18.1 Complete Hub Options

// Program.cs — All available options
builder.Services.AddSignalR(options =>
{
    // ────── KeepAlive ──────
    // Interval for sending keepalive pings from server to client
    // Default: 15 seconds
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);

    // Delay after which server considers client disconnected
    // if no message received. Must be >= 2 × KeepAliveInterval
    // Default: 30 seconds
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(30);

    // ────── Message size ──────
    // Maximum size of an incoming message (DoS protection)
    // Default: 32,768 bytes (32 KB)
    options.MaximumReceiveMessageSize = 65_536; // 64 KB

    // Streaming buffer size (queued items)
    // Default: 10
    options.StreamBufferCapacity = 10;

    // ────── Parallelism ──────
    // Max parallel invocations per client connection
    // Default: 1 (sequential) — increase with caution
    options.MaximumParallelInvocationsPerClient = 1;

    // ────── Development ──────
    // Send exception details to clients
    // NEVER enable in production (sensitive information leak)
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});

18.2 KeepAlive — Detailed Operation

sequenceDiagram
    participant C as Client
    participant S as Server

    Note over C,S: Active connection, no user activity
    S->>C: KeepAlive ping (type:6) — every 15s
    C-->>S: KeepAlive pong (type:6)

    Note over C,S: Simulation: network cut
    S->>C: KeepAlive ping (type:6)
    Note over S: No response...
    S->>C: KeepAlive ping (type:6) — +15s
    Note over S: Still no response...
    Note over S: ClientTimeoutInterval (30s) reached
    S-xC: OnDisconnectedAsync(TimeoutException)
    C->>S: Automatic reconnection (if configured)

Absolute rule: ClientTimeoutInterval >= 2 × KeepAliveInterval
Otherwise, the server disconnects the client before the ping is received.

18.3 CORS — Correct Configuration

builder.Services.AddCors(options =>
{
    options.AddPolicy("SignalRCors", policy =>
    {
        policy
            // Explicit origin list (NEVER AllowAnyOrigin with credentials)
            .WithOrigins(
                "https://app.mysite.com",
                "https://localhost:3000",
                "https://localhost:7150")
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials(); // REQUIRED for SignalR
    });
});

var app = builder.Build();

// IMPORTANT: UseCors BEFORE MapHub
app.UseCors("SignalRCors");
app.MapHub<AuctionHub>("/auctionhub");

19. Azure SignalR Service — Deployment Guide

19.1 Why Azure SignalR Service?

Without Azure SignalR Service, your ASP.NET Core server manages all WebSocket persistent connections itself. Each connection consumes resources (memory, file descriptors). Scale-out requires a Redis backplane.

Azure SignalR Service moves connections to the cloud:

graph TD
    subgraph "Without Azure SignalR Service"
        Clients1["Thousands of clients\n(WebSocket connections)"] -->|"All connections"| AppServer1["Your server\n(quickly saturated)"]
        AppServer1 --> DB1["Database"]
    end

    subgraph "With Azure SignalR Service"
        Clients2["Thousands of clients\n(WebSocket connections)"] -->|"Managed connections"| AzSR["Azure SignalR Service\n(managed by Microsoft)"]
        AzSR <-->|"Lightweight tunnel\n(few connections)"| AppServer2["Your server\n(freed up)"]
        AppServer2 --> DB2["Database"]
    end

    style AzSR fill:#0078D4,color:white

19.2 Creating the Service in Azure

# Via Azure CLI
az group create --name rg-auction --location eastus

az signalr create \
    --name signalr-auction-app \
    --resource-group rg-auction \
    --sku Free_F1 \
    --service-mode Default

# Retrieve the connection string
az signalr key list \
    --name signalr-auction-app \
    --resource-group rg-auction \
    --query primaryConnectionString \
    --output tsv

19.3 Configuration in ASP.NET Core

// Program.cs — Enable Azure SignalR Service
builder.Services.AddSignalR()
    .AddAzureSignalR(options =>
    {
        options.ConnectionString = builder.Configuration["AzureSignalR:ConnectionString"];

        // Or configure multiple endpoints for high availability
        options.Endpoints = new[]
        {
            new ServiceEndpoint(
                builder.Configuration["AzureSignalR:Primary"],
                EndpointType.Primary),
            new ServiceEndpoint(
                builder.Configuration["AzureSignalR:Secondary"],
                EndpointType.Secondary)
        };
    });

app.MapHub<AuctionHub>("/auctionhub");
// appsettings.json — NEVER commit the access key!
{
    "AzureSignalR": {
        "ConnectionString": "Endpoint=https://signalr-auction.service.signalr.net;AccessKey=YOUR_KEY;Version=1.0;"
    }
}

20. Authentication and Authorization — Complete Guide

Cookie authentication is the simplest to set up with SignalR. The cookie is sent automatically during the WebSocket connection (HTTP → WS upgrade), with no special configuration.

20.2 JWT Token Authentication — Complete Configuration

// Program.cs — JWT Bearer for API + SignalR
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = builder.Configuration["Auth:Authority"];
        options.Audience  = builder.Configuration["Auth:Audience"];

        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer           = true,
            ValidateAudience         = true,
            ValidateLifetime         = true,
            ValidateIssuerSigningKey = true,
            ClockSkew                = TimeSpan.FromSeconds(30)
        };

        // ─── CRUCIAL for SignalR with WebSockets ───
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                var token = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(token) &&
                    path.StartsWithSegments("/auctionhub"))
                {
                    context.Token = token;
                }
                return Task.CompletedTask;
            }
        };
    });

20.3 Granular Policy-Based Authorization

// Program.cs — Define authorization policies
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("AuthenticatedUser", policy =>
        policy.RequireAuthenticatedUser());

    options.AddPolicy("AdminOnly", policy =>
        policy.RequireRole("Admin"));

    options.AddPolicy("PremiumBidder", policy =>
        policy.RequireClaim("subscription", "premium", "enterprise"));
});
// Hub — Granular authorization per method
[Authorize] // Entire Hub requires authentication
public class AuctionHub : Hub<IAuctionHubClient>
{
    // Accessible to all authenticated users
    public async Task NotifyNewBid(BidInfo bidInfo)
    {
        await Clients.All.ReceiveNewBid(bidInfo);
    }

    // Reserved for premium subscribers
    [Authorize(Policy = "PremiumBidder")]
    public async Task PlaceReservedBid(BidInfo bidInfo)
    {
        await Clients.All.ReceiveNewBid(bidInfo);
    }

    // Reserved for administrators
    [Authorize(Policy = "AdminOnly")]
    public async Task CloseAuction(int auctionId)
    {
        await Clients.All.AuctionClosed(auctionId, null);
    }
}

21. Demo Application — Complete Auction System

21.1 Architecture Overview

graph TD
    subgraph "Client (Browser)"
        HTML["Views/Home/Index.cshtml\n(Razor)"]
        JS["wwwroot/js/homeindex.js\n(SignalR Client)"]
    end

    subgraph "ASP.NET Core Server"
        Controller["HomeController\n(Initial render)"]
        MinAPI["Minimal APIs\nGET /auctions\nPOST /auction/{id}/newbid\nPOST /auction"]
        Hub["AuctionHub\n(Hub<IAuctionHubClient>)\n[Authorize]"]
        BgSvc["AuctionClosingService\n(BackgroundService)"]
    end

    subgraph "Infrastructure"
        Repo["InMemoryAuctionRepository\n(or SQL via EF Core)"]
        AzSR["Azure SignalR Service\n(optional, scale-out)"]
    end

    HTML --> JS
    JS <-->|"WebSocket\n/auctionhub"| Hub
    JS -->|"REST\nfetch()"| MinAPI
    Controller --> Repo
    MinAPI --> Repo
    Hub --> Repo
    BgSvc --> Repo
    MinAPI -->|"IHubContext"| Hub
    BgSvc -->|"IHubContext"| Hub
    Hub <-->|"If configured"| AzSR

    style Hub fill:#2196F3,color:white
    style AzSR fill:#0078D4,color:white

22. Best Practices and Common Pitfalls

22.1 The 15 Golden Rules of SignalR

Rule 1 — SignalR = notifications, REST = data

Why? The SignalR connection can be interrupted. If persistence depends on SignalR, data may be lost. Always use a REST API for critical operations.

Rule 2 — Always check connection state before invoking

// ❌ Risk of error if disconnected
await connection.invoke("NotifyNewBid", bidInfo);

// ✅ Check state before invoking
if (connection.state === signalR.HubConnectionState.Connected) {
    await connection.invoke("NotifyNewBid", bidInfo);
} else {
    location.reload();
}

Rule 3 — Never store ConnectionId as a permanent identifier

// ❌ ConnectionId changes on every reconnection
_userConnections[Context.ConnectionId] = userId;

// ✅ Use UserIdentifier (stable)
await Clients.User(userId).ReceiveNewBid(bidInfo);

Rule 4 — Use Hub in production

// ❌ Magic strings — silent errors
await Clients.All.SendAsync("RecieveNewBid", bidInfo); // Typo!

// ✅ Strongly typed — compile-time errors
await Clients.All.ReceiveNewBid(bidInfo);

Rule 10 — Notify AFTER persistence (never before)

// ❌ Notify before persisting — clients may see unsaved data
await hubContext.Clients.All.ReceiveNewBid(bidInfo);
await repo.SaveAsync(bid); // May fail!

// ✅ Persist first, then notify
await repo.SaveAsync(bid);
await hubContext.Clients.All.ReceiveNewBid(bidInfo);

22.2 Common Pitfalls Table

PitfallSymptomCauseSolution
Magic stringsMethod never called client-sideTypo in SendAsync("...")Use Hub<T>
Unstable ConnectionIdState lost after reconnectionConnectionId changes on reconnectionUse UserIdentifier
Business logic in HubApp unusable without SignalRHub does everythingSeparate REST / SignalR
Wrong CORS configAccess-Control-Allow-Origin missingAllowAnyOrigin + AllowCredentialsExplicit origins
JWT token not found401 on WebSocket connectionHeaders not transmitted in WSOnMessageReceived + QueryString
Scale-out without backplaneRandom lost messagesMultiple instances without coordinationRedis or Azure SignalR
EnableDetailedErrors in prodStack traces exposed to clientConfig not changedfalse in production
Notify before persistingClient sees unsaved dataWrong orderPersist → notify
base.OnConnected forgottenConnection badly initializedIncomplete overrideAlways call base

23. Advanced Review Questions

Q13 — Streaming

Question: What is the difference between IAsyncEnumerable<T> and ChannelReader<T> for streaming in a Hub? When should you use one or the other?

Answer: Both allow server→client streaming. IAsyncEnumerable<T> is the modern approach (C# 8+), more readable with yield return, and natively integrates CancellationToken propagation via the [EnumeratorCancellation] attribute. ChannelReader<T> is the older API, useful when multiple producers write to the channel. For new applications, prefer IAsyncEnumerable<T>.


Q14 — BFF Pattern

Question: Why is the BFF (Backend for Frontend) pattern recommended for SPAs that use SignalR with JWT?

Answer: The BFF solves two problems: 1) JWT tokens should not be stored in localStorage (vulnerable to XSS attacks). The BFF stores the token server-side and only exposes an HttpOnly, SameSite=Strict cookie. 2) WebSockets do not support custom headers: with BFF + cookie, authentication works naturally because cookies are automatically sent during the HTTP→WebSocket upgrade.


Q20 — Performance — Clients.All vs Clients.Group

Question: You have 10,000 active connections. 1,000 clients are interested in item #42. What is the performance difference between Clients.All.ReceiveNewBid(bidInfo) and Clients.Group("auction-42").ReceiveNewBid(bidInfo)?

Answer: Clients.All sends the message to 10,000 connections — the majority is wasted and consumes bandwidth unnecessarily. Clients.Group("auction-42") sends to only 1,000 targeted connections, that is 90% fewer messages. For high-traffic applications, groups are essential: they reduce network load, improve scalability, and avoid flooding clients with updates that don’t concern them.


Q22 — Security — Dependency Injection in a Hub

Question: Are Hubs singletons, scoped, or transient? What implication does this have for service injection?

Answer: Hub instances are transient — a new instance is created for each Hub method invocation. You can inject scoped services (e.g., DbContext) directly into the constructor without risk of captive dependency. Singleton services are also injectable. However, never store state in Hub properties — this state would be lost between invocations. Use injected services for state persistence.


ASP.NET Core SignalR Fundamentals — Complete Guide


Search Terms

asp.net · core · signalr · fundamentals · web · apis · c# · .net · development · hub · client · configuration · typed · authentication · connection · rule · streaming · azure · server · service · architecture · authorization · jwt · strongly

Interested in this course?

Contact us to book it or get a custom training plan for your team.