SignalR is ASP.NET Core’s real-time communication library — it abstracts WebSockets (with fallbacks to Server-Sent Events and long polling) behind a simple hub/client model. A Hub is the server-side class that receives method calls from clients and can invoke methods on connected clients. Using a strongly-typed client interface (Hub<IBlogHubClient>) gives compile-time safety for the method names the server calls on clients.
BlogHub Implementation
// ── Strongly-typed client interface ───────────────────────────────────────
public interface IBlogHubClient
{
Task ReceiveComment(CommentDto comment);
Task UpdateViewerCount(int postId, int count);
Task ReceiveNotification(NotificationDto notification);
Task CommentStatusChanged(int commentId, string status);
}
// ── Hub implementation ────────────────────────────────────────────────────
[Authorize] // all hub connections require JWT auth (public methods override below)
public class BlogHub : Hub<IBlogHubClient>
{
private readonly ICommentsService _comments;
private readonly INotificationService _notifications;
private static readonly ConcurrentDictionary<string, int> _roomViewers = new();
// ── Connection lifecycle ──────────────────────────────────────────────
public override async Task OnConnectedAsync()
{
await base.OnConnectedAsync();
// Re-join any groups the user was in before disconnection (if stored)
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
// Clean up any room memberships
await base.OnDisconnectedAsync(exception);
}
// ── Client-invokable hub methods ──────────────────────────────────────
// Join a post's real-time room (public — no [Authorize] needed)
[AllowAnonymous]
public async Task JoinPostRoom(int postId)
{
var groupName = $"post-{postId}";
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
// Increment and broadcast viewer count
var count = _roomViewers.AddOrUpdate(groupName, 1, (_, c) => c + 1);
await Clients.Group(groupName).UpdateViewerCount(postId, count);
}
[AllowAnonymous]
public async Task LeavePostRoom(int postId)
{
var groupName = $"post-{postId}";
await Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
var count = _roomViewers.AddOrUpdate(groupName, 0, (_, c) => Math.Max(0, c - 1));
await Clients.Group(groupName).UpdateViewerCount(postId, count);
}
// Submit a comment (requires auth)
public async Task SendComment(int postId, string body)
{
var authorId = Context.UserIdentifier!;
// Save via service (also validates ownership etc.)
var comment = await _comments.CreateAsync(postId, authorId, body);
// Broadcast to all viewers of this post
await Clients.Group($"post-{postId}").ReceiveComment(comment);
// Notify the post author if different from commenter
if (comment.PostAuthorId != authorId)
await Clients.User(comment.PostAuthorId).ReceiveNotification(
new NotificationDto($"New comment on '{comment.PostTitle}'",
$"/posts/{comment.PostSlug}"));
}
}
// ── Program.cs — SignalR setup ─────────────────────────────────────────────
builder.Services.AddSignalR(opts =>
{
opts.EnableDetailedErrors = builder.Environment.IsDevelopment();
opts.KeepAliveInterval = TimeSpan.FromSeconds(15);
opts.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
});
// ── JWT for WebSocket connections — can't use Authorization header ─────────
// WebSockets cannot set custom headers — JWT is passed as query param
builder.Services.Configure<JwtBearerOptions>(
JwtBearerDefaults.AuthenticationScheme, opts =>
{
opts.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
var token = ctx.Request.Query["access_token"];
var path = ctx.HttpContext.Request.Path;
if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hubs"))
ctx.Token = token;
return Task.CompletedTask;
}
};
});
// In middleware pipeline:
app.MapHub<BlogHub>("/hubs/blog");
Authorization: Bearer token header for SignalR connections. The conventional workaround: pass the token as a query parameter (?access_token=eyJ...) and use the OnMessageReceived event to extract it. The SignalR JavaScript client does this automatically when configured — set the accessTokenFactory option (shown in the next lesson) and the client appends the token as a query parameter.Hub<IBlogHubClient> pattern instead of plain Hub. The plain hub uses Clients.All.SendAsync("ReceiveComment", comment) with string method names — a typo compiles but fails silently at runtime. The strongly-typed version uses Clients.All.ReceiveComment(comment) — compile-time checked, IntelliSense-supported, and refactoring-safe. Define IBlogHubClient as a shared interface that both the hub and the client TypeScript type definition mirror._roomViewers ConcurrentDictionary is in-memory — it works correctly for a single-server deployment but is incorrect when multiple app instances are running (each instance has its own dictionary). With a load balancer and 3 app instances, joining a room on Instance A increments only Instance A’s counter; viewers on Instances B and C see incorrect counts. For multi-instance deployments, use Azure SignalR Service (covered in the final lesson) which manages group membership centrally.Common Mistakes
Mistake 1 — JWT auth not configured for WebSocket connections (hub methods always unauthorized)
❌ Wrong — standard AddJwtBearer() without OnMessageReceived; WebSocket connections cannot pass Authorization header; all authenticated hub calls fail.
✅ Correct — configure OnMessageReceived to extract token from access_token query parameter for SignalR paths.
Mistake 2 — In-memory group state with multiple app instances (incorrect viewer counts)
❌ Wrong — ConcurrentDictionary for room counts; load balancer distributes connections across instances; each instance has partial state.
✅ Correct — Azure SignalR Service for production multi-instance deployments; in-memory only for single-instance or development.