Real-world SignalR applications require more than basic hub methods — they need reliable reconnection, presence tracking, strongly-typed hub interfaces, and thoughtful architecture decisions. The patterns in this lesson represent production-grade SignalR usage: typed hubs for compile-time safety, presence tracking for “who is online” features, background services for periodic push updates, and clear guidelines on when SignalR is the right tool versus simpler alternatives.
Typed Hub — Strongly-Typed Client Interface
// ── Define the client interface (methods the server calls on the client) ───
public interface IPostHubClient
{
Task PostPublished(PostSummaryDto post);
Task PostUpdated(PostUpdateDto update);
Task NewComment(CommentDto comment);
Task ViewCountUpdated(int postId, int viewCount);
Task UserJoined(string userId, string displayName);
Task UserLeft(string userId);
}
// ── Typed Hub — compile-time checked client method names ─────────────────
public class PostHub : Hub<IPostHubClient>
{
// Now Clients.Caller, Clients.All, etc. are typed to IPostHubClient
public async Task SubscribeToPost(int postId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, $"post-{postId}");
// Type-safe — PostSummaryDto shape is verified at compile time
await Clients.Caller.PostPublished(new PostSummaryDto { Id = postId });
}
}
// ── IHubContext with typed interface ──────────────────────────────────────
public class PostService(IHubContext<PostHub, IPostHubClient> hubContext) : IPostService
{
public async Task PublishAsync(int id, CancellationToken ct)
{
var post = await _repo.PublishAsync(id, ct);
// Compile-time checked — PostSummaryDto must match IPostHubClient.PostPublished
await hubContext.Clients.All.PostPublished(post.ToSummaryDto());
}
}
Clients.All.SendAsync("PostPublished", dto)), a typo in the method name (“PostPublishe”) compiles successfully but fails silently at runtime — the Angular client never receives the message. With typed hubs, hubContext.Clients.All.PostPublished(dto) is a real method call that fails at compile time if the method name or argument type is wrong. Typed hubs are the recommended approach for any production SignalR implementation.BackgroundService that periodically reads from Redis counters and broadcasts updates via IHubContext. Rather than pushing on every increment (potentially hundreds per second), batch updates on a timer: every 5 seconds, read the current view count and broadcast if changed. This prevents overwhelming clients with tiny increments while still feeling real-time for UX purposes. The “feels like real-time” threshold for humans is about 200–500ms — 5-second updates are imperceptible as “not live” for a view counter.Presence Tracking
// ── Track who is currently viewing each post ─────────────────────────────
public class PostHub(
IConnectionMultiplexer redis,
IHubContext<PostHub, IPostHubClient> hubContext) : Hub<IPostHubClient>
{
private const string PresenceKeyPrefix = "presence:post:";
public async Task StartViewingPost(int postId)
{
var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
var displayName = Context.User?.FindFirstValue("displayName") ?? "Anonymous";
var groupName = $"post-{postId}";
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
// Track in Redis (TTL auto-expires on connection drop)
if (userId is not null)
{
var db = redis.GetDatabase();
await db.HashSetAsync($"{PresenceKeyPrefix}{postId}", userId, displayName);
await db.KeyExpireAsync($"{PresenceKeyPrefix}{postId}", TimeSpan.FromMinutes(5));
}
// Notify others in the group that this user joined
await Clients.OthersInGroup(groupName).UserJoined(userId ?? "anon", displayName);
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
var userId = Context.User?.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is not null)
{
// Remove from all post presence sets
// In production: track which groups this connection is in
}
await base.OnDisconnectedAsync(exception);
}
}
When to Use SignalR vs Polling
| Scenario | SignalR | Polling |
|---|---|---|
| Live chat | ✅ Sub-second latency needed | Too slow |
| Order status updates | ✅ Instant notification | 30s polling OK |
| Dashboard refresh | Possible | ✅ Simpler, reliable |
| Presence (“online now”) | ✅ Real-time accuracy | Too slow |
| New post notification | ✅ Instant feel | 1-min polling OK |
| Export completion | Good | ✅ Simpler |
Common Mistakes
Mistake 1 — Using untyped SendAsync for all client calls (silent runtime failures on typos)
❌ Wrong — Clients.All.SendAsync("PostPublihsed", dto) — typo compiles, silently fails at runtime.
✅ Correct — use typed Hub (Hub<IPostHubClient>) for compile-time method name and type checking.
Mistake 2 — Using SignalR for scenarios where polling is simpler and sufficient
❌ Wrong — SignalR for a dashboard that refreshes every minute; adds complexity for no perceptible benefit.
✅ Correct — evaluate latency requirement; use SignalR only when sub-5-second push latency is genuinely needed.