Version: ASP.NET Core 8 (100% applicable to ASP.NET Core 5+) Demo Application: Real-Time Auction System
Table of Contents
- Introduction — The Power of SignalR
- Hub and RPC Architecture
- Transports and Negotiation
- Connection Lifecycle
- Strongly Typed Hubs
- Advanced Server Features
- JavaScript Client — Complete Guide
- .NET Client — Complete Guide
- Authentication and Authorization
- Hosting and Scaling
- Performance and Advanced Configuration
- Demo Application — Full Auction System
- Summary and Best Practices
- 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
| Domain | Examples |
|---|---|
| E-commerce | Auction systems, real-time inventory |
| Monitoring | Live dashboards, streaming logs |
| Collaboration | Chat applications, collaborative editing |
| Finance | Stock prices, cryptocurrencies |
| Gaming | Online multiplayer games |
| DevOps | CI/CD pipelines, deployment progress |
| IoT | Real-time sensor data |
1.4 SignalR Alternatives (when not to use it)
| Technology | When to use | Limitation vs SignalR |
|---|---|---|
| Manual polling | Rarely changing data | Inefficient, delayed |
| Native Server-Sent Events | Unidirectional stream only | No client→server push |
| Raw WebSockets | Full control, custom protocol | No abstraction, more complex |
| gRPC streaming | Internal microservices | Not 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
| Transport | Duplex | TCP | Compatibility | Usage |
|---|---|---|---|---|
| WebSockets | Full | Persistent | 95%+ | Default |
| Server-Sent Events | Server→Client | 1 connection | Modern browsers | Fallback 1 |
| Long Polling | Simulated | 1 per message | Universal | Fallback 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
ConnectionIdis generated. Do not storeConnectionIdas 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
ReceiveNewBidin 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
ConnectionIdis 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
9.1 Cookie Authentication (ASP.NET Core Identity)
// 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:
| Plan | Max WebSocket connections |
|---|---|
| Free / Shared | Not supported |
| Basic B1 | 5 |
| Standard S2 | 350 |
| Premium P2v3 | 3500 |
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
| Solution | Complexity | Cost | Recommended for |
|---|---|---|---|
| Single instance | None | Free | Small apps |
| Sticky Sessions | Low | Free | Non-WebSocket, few instances |
| Redis Backplane | Medium | ~$15/month | On-premise, multi-cloud |
| Azure SignalR Service | Low | Variable | Azure 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
| Parameter | Default | Recommendation |
|---|---|---|
MaximumReceiveMessageSize | 32 KB | Increase if needed, not too much (DoS risk) |
KeepAliveInterval | 15s | 10-15s recommended |
ClientTimeoutInterval | 30s | Minimum 2× KeepAliveInterval |
MaximumParallelInvocationsPerClient | 1 | Increase with caution |
| Protocol | JSON | MessagePack 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
| Pitfall | Problem | Solution |
|---|---|---|
| Magic strings | Silent typos | Use Hub<T> with interface |
| Unstable ConnectionId | Changes on every reconnection | Use UserIdentifier |
| Business logic in Hub | Tight coupling, hard to test | REST API + Hub for notifications |
| Scale-out without backplane | Lost messages | Redis or Azure SignalR Service |
| Token in WS header | WebSockets without headers | QueryString access_token |
CORS without AllowCredentials | Connection refused | .AllowCredentials() |
EnableDetailedErrors in prod | Sensitive info leak | false in production |
| No fallback | App unusable if SignalR goes down | Always REST first |
13.3 Security Checklist
- HTTPS/WSS — Always in production
- Authentication —
OnMessageReceivedfor JWT via QueryString - Authorization —
[Authorize]on Hub and/or sensitive methods - CORS —
AllowCredentials()with explicit origins (notAllowAnyOrigin) - Validation — Validate all parameters in Hub methods
- MaximumReceiveMessageSize — Limit to prevent DoS attacks
- EnableDetailedErrors —
falsein production - Connection strings — Use appsettings / Key Vault, never hardcoded
13.4 Library Reference
| Technology | Package | Notes |
|---|---|---|
| JavaScript Browser | @microsoft/signalr | npm or LibMan |
| .NET (Console, WPF, MAUI) | Microsoft.AspNetCore.SignalR.Client | NuGet |
| Java / Android | com.microsoft.signalr | Maven |
| Blazor | Built-in | Similar API to .NET client |
| MessagePack Server | Microsoft.AspNetCore.SignalR.Protocols.MessagePack | Optional |
| MessagePack JS Client | @microsoft/signalr-protocol-msgpack | npm |
| Redis Backplane | Microsoft.AspNetCore.SignalR.StackExchangeRedis | Scale-out |
| Azure SignalR Service | Microsoft.Azure.SignalR | Cloud scale-out |
| BFF | Duende.BFF | Browser 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
| Aspect | Hub (untyped) | Hub<T> (typed) |
|---|---|---|
| Method name | Manual string "ReceiveNewBid" | Compiled method .ReceiveNewBid(...) |
| Verification | Runtime (too late) | Compile time |
| IntelliSense | None | Complete |
| Refactoring | Broken | Automatic |
| Type safety | Weak | Strong |
| Initial complexity | Low | Slightly higher |
| Recommended | Rapid prototyping | Production |
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
20.1 Cookie Authentication (ASP.NET Core Identity)
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
| Pitfall | Symptom | Cause | Solution |
|---|---|---|---|
| Magic strings | Method never called client-side | Typo in SendAsync("...") | Use Hub<T> |
Unstable ConnectionId | State lost after reconnection | ConnectionId changes on reconnection | Use UserIdentifier |
| Business logic in Hub | App unusable without SignalR | Hub does everything | Separate REST / SignalR |
| Wrong CORS config | Access-Control-Allow-Origin missing | AllowAnyOrigin + AllowCredentials | Explicit origins |
| JWT token not found | 401 on WebSocket connection | Headers not transmitted in WS | OnMessageReceived + QueryString |
| Scale-out without backplane | Random lost messages | Multiple instances without coordination | Redis or Azure SignalR |
EnableDetailedErrors in prod | Stack traces exposed to client | Config not changed | false in production |
| Notify before persisting | Client sees unsaved data | Wrong order | Persist → notify |
base.OnConnected forgotten | Connection badly initialized | Incomplete override | Always 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