SignalR Patterns — Notifications, Live Updates and Presence

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());
    }
}
Note: Typed hubs provide compile-time verification that client method names and argument types are correct. With untyped hubs (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.
Tip: For live metrics (view count, online users), use a 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.
Warning: SignalR is not always the right tool. If your “real-time” requirement is “refresh every 30 seconds,” polling is simpler, more reliable, and works behind all proxies and firewalls. SignalR adds connection management complexity, requires a backplane for multi-instance, and has special CORS requirements. Use SignalR when you need sub-second push latency (live chat, collaborative editing, presence), instant notifications (order status), or when you need the server to push without client requests. For anything that can tolerate 15–60 second staleness, a polling strategy is often the better engineering choice.

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.

🧠 Test Yourself

An Angular client using withAutomaticReconnect() disconnects briefly and reconnects. It was previously in group “post-42”. Is it still in the group?