SignalR
Key Points
- SignalR is ASP.NET Core's real-time bidirectional comms library. Picks the best transport: WebSockets (preferred), Server-Sent Events, Long Polling.
- Hubs are the abstraction. Server methods callable from client; server can push to clients via
IHubContext. - Scaling: single-instance OK for small apps. Multi-instance needs a backplane (Redis, Azure SignalR Service) so clients on different servers see each other.
- Azure SignalR Service handles the backplane and connection scaling — server stays light, just emits messages. Best for serverless / massive scale.
- MessagePack vs JSON — MessagePack is faster, smaller; default for new projects.
Concepts (deep dive)
Hubs
public class ChatHub : Hub
{
public async Task SendMessage(string user, string msg)
=> await Clients.All.SendAsync("ReceiveMessage", user, msg);
public override async Task OnConnectedAsync()
{
await Groups.AddToGroupAsync(Context.ConnectionId, "everyone");
await base.OnConnectedAsync();
}
}
builder.Services.AddSignalR();
app.MapHub<ChatHub>("/chat");
Client (JS):
const conn = new signalR.HubConnectionBuilder().withUrl("/chat").build();
conn.on("ReceiveMessage", (user, msg) => console.log(user, msg));
await conn.start();
await conn.invoke("SendMessage", "Alice", "Hi");
Strongly-typed hubs
public interface IChatClient
{
Task ReceiveMessage(string user, string msg);
Task UserJoined(string user);
}
public class ChatHub : Hub<IChatClient>
{
public async Task SendMessage(string user, string msg)
=> await Clients.All.ReceiveMessage(user, msg); // strongly typed
}
Compile-time checking on method names.
Pushing from outside the hub
public class NotificationService(IHubContext<ChatHub, IChatClient> hub)
{
public Task Notify(string user, string msg)
=> hub.Clients.User(user).ReceiveMessage("system", msg);
}
Inject IHubContext to push from controllers, background services.
Targeting clients
Clients.All
Clients.Caller
Clients.Others
Clients.User(userId) // requires UserIdProvider
Clients.Users(new[] { id1, id2 })
Clients.Group("groupName")
Clients.Client(connectionId)
Groups
await Groups.AddToGroupAsync(Context.ConnectionId, "room-42");
await Clients.Group("room-42").SendAsync("Message", payload);
Group membership lost on disconnect; rejoin on reconnect (in OnConnectedAsync).
User identification
public class CustomUserIdProvider : IUserIdProvider
{
public string? GetUserId(HubConnectionContext c) => c.User?.FindFirst("sub")?.Value;
}
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
By default, ClaimTypes.NameIdentifier claim. Customize for JWT sub etc.
Authentication
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
if (ctx.Request.Path.StartsWithSegments("/chat") && ctx.Request.Query.TryGetValue("access_token", out var token))
ctx.Token = token;
return Task.CompletedTask;
}
};
});
[Authorize]
public class ChatHub : Hub { /* ... */ }
WebSocket connect URLs can pass token in query (browsers can't set Authorization header on WS handshake).
Scaling: backplane
Single instance: works.
Multi-instance:
Client → Server A: connection
Client → Server B: connection
Server A's Clients.All → only A's clients (not B's!)
Backplane fixes this — broadcasts published messages to all servers.
// Redis backplane
builder.Services.AddSignalR().AddStackExchangeRedis("redis:6379");
// Azure SignalR Service
builder.Services.AddSignalR().AddAzureSignalR("Endpoint=https://...");
Azure SignalR Service
Managed; offloads connection scaling. Your app emits messages; Azure SignalR fans out.
Massive scale (>100K connections per unit). No state in your app.
Streaming
Hub returning IAsyncEnumerable<T> streams to client:
public async IAsyncEnumerable<int> Counter(int delay, [EnumeratorCancellation] CancellationToken ct)
{
for (int i = 0; ; i++)
{
ct.ThrowIfCancellationRequested();
await Task.Delay(delay, ct);
yield return i;
}
}
conn.stream("Counter", 1000).subscribe({
next: v => console.log(v),
complete: () => console.log("done")
});
Backpressure
builder.Services.AddSignalR(o =>
{
o.MaximumReceiveMessageSize = 32 * 1024;
o.StreamBufferCapacity = 10;
});
Bounded buffer for streams. Backpressure when client slow.
Connection lifecycle
OnConnectedAsync— joined.OnDisconnectedAsync(Exception?)— left (graceful or error).- Implement reconnect logic on client.
Auto-reconnect (client)
const conn = new signalR.HubConnectionBuilder()
.withUrl("/chat")
.withAutomaticReconnect([0, 2000, 10000, 30000])
.build();
Reconnect attempts with backoff.
When SignalR vs raw WebSockets
- SignalR: high-level abstraction; transport fallback; multi-server scaling; auth integration.
- Raw WebSockets: when you need binary protocol control or SignalR overhead is too much.
For 95% of real-time .NET, SignalR.
Performance
WebSocket: lowest overhead. SSE: server→client only; HTTP. Long polling: fallback; expensive.
MessagePack protocol (.AddMessagePackProtocol()) — smaller, faster than JSON.
Heartbeats
Detect dead connections.
Common issues
- Sticky sessions: WebSocket connections need to stay on the same server. With LB, use sticky cookies or backplane.
- Groups not persisting — explicitly rejoin in
OnConnectedAsync. - Static
IHubContextfor background jobs — bad; inject via DI.
Code: correct vs wrong
❌ Wrong: backplane missing in multi-instance
✅ Correct: Redis or Azure
❌ Wrong: pushing from controller without IHubContext
var hub = new ChatHub(); // can't instantiate; not a hub instance
hub.Clients.All.SendAsync(...); // null
✅ Correct: inject IHubContext
Design patterns for this topic
Pattern 1 — "Strongly-typed hubs"
- Intent: compile-time client method names.
Pattern 2 — "Backplane for scale-out"
- Intent: Redis or Azure SignalR Service.
Pattern 3 — "Streaming hub methods"
- Intent: push series of values.
Pattern 4 — "MessagePack protocol"
- Intent: smaller; faster than JSON.
Pattern 5 — "Auto-reconnect on client"
- Intent: UX through transient outages.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| SignalR | Easy API; transport fallback | Some overhead |
| Raw WebSockets | Lean | Manual scaling; auth |
| Azure SignalR | Massive scale | Vendor cost |
| Redis backplane | Cheap | Operational |
When to use / when to avoid
- Use for chat, dashboards, notifications, collaborative features.
- Use Azure SignalR for serverless / huge scale.
- Avoid for simple request-response.
Interview Q&A
Q1. SignalR transports? WebSockets (preferred), Server-Sent Events, Long Polling. Auto-fallback.
Q2. Why a backplane? Multi-instance: Server A's broadcast doesn't reach Server B's clients. Backplane synchronizes.
Q3. Backplane options? Redis, Azure SignalR Service, SQL Server (deprecated).
Q4. Strongly-typed hub? Hub<T> where T is a client interface. Server calls Clients.All.MethodName(...) — strongly typed.
Q5. Push from outside hub? Inject IHubContext<THub> or IHubContext<THub, TClient> and call.
Q6. JWT for SignalR? WS handshake can't set headers; pass token via query string; JwtBearer's OnMessageReceived reads it.
Q7. Groups persist across reconnect? No. Rejoin in OnConnectedAsync.
Q8. Streaming hub method? Return IAsyncEnumerable<T>. Client subscribes via .stream(...).
Q9. MessagePack vs JSON? MessagePack: binary; smaller; faster. Recommended.
Q10. Sticky sessions necessary? WebSocket: yes (connection bound to one server). With backplane, broadcasting works across; but the WS itself is sticky.
Q11. Azure SignalR Service benefits? Managed connection scaling; offloads from your app. Pay per connection-second.
Q12. KeepAlive vs ClientTimeout? KeepAlive: server pings client. ClientTimeout: max silence before considered dead.
Gotchas / common mistakes
- ⚠️ No backplane in multi-instance — broadcasts incomplete.
- ⚠️ Token in Authorization header for WS — browsers can't set; use query.
- ⚠️ Groups assumed persistent — rejoin on reconnect.
- ⚠️ Static IHubContext — wrong lifetime.
- ⚠️ Default JSON without compression — bandwidth.