SignalR Best Practices, Patterns, and Anti-Patterns

August 25, 2026SignalR Best Practices, Patterns, and Anti-Patterns
Sarah Dutkiewicz

Sarah Dutkiewicz, Senior Trainer

Through this series, we’ve gone from zero to a full real-time toolbox: the fundamentals (part 1), connections and hubs (part 2), collaborative features with groups (part 3), streaming data updates (part 4), and instant notifications (part 5). This capstone post is a bit different: instead of building a new feature, we’re going to pull everything together and talk about how to build SignalR apps well.

We’ll look at three things:

  • Best practices - habits that keep real-time apps reliable and maintainable.
  • Patterns - the recurring shapes we reached for again and again across the series.
  • Anti-patterns - the common mistakes that cause the most pain, and how to avoid them.

Think of this as the consolidated field guide to the series. If you’re about to add real-time features to an app, this is the post to skim before you write your first hub.

Note: All code demos for this series are available in nimblepros/signalr-code-demo.

Best Practices ✅

These are the habits that kept showing up in every example we built. They’re cheap to adopt and they prevent whole categories of bugs.

Treat connections as resources that need managing 🔌

A HubConnection is a real, finite resource - it holds a socket, it sends heartbeats, and it keeps state on the server. The client-side rules we used in every component are worth stating explicitly:

  • Register your handlers before you call StartAsync(). Otherwise, a message that arrives right after the connection opens is silently dropped. This was a deliberate pattern in part 2 and it should be a habit, not an afterthought.
  • Dispose connections when you’re done with them. In Blazor, a component that creates a connection should implement IAsyncDisposable and call DisposeAsync() on the connection. Skip this and you leak connections (and their keep-alive timers) for the lifetime of the app.
  • Turn on automatic reconnection. WithAutomaticReconnect() is one line and it saves users from “please refresh” moments. The default retry schedule is fine for most apps, but when you have specific needs, write your own retry policy:
HubConnection connection = new HubConnectionBuilder()
    .WithUrl("/chathub")
    .WithAutomaticReconnect(new SimpleRetryPolicy())
    .Build();

// A simple custom policy: retry every 2 seconds, for up to 30 seconds
// total, then give up so the UI can tell the user to try again later.
class SimpleRetryPolicy : IRetryPolicy
{
    private static readonly TimeSpan Delay = TimeSpan.FromSeconds(2);
    private static readonly TimeSpan MaxTotal = TimeSpan.FromSeconds(30);

    public TimeSpan? GetNextRetryDelay(RetryContext retryContext) =>
        retryContext.ElapsedTime < MaxTotal ? Delay : null;
}
  • Plan for what happens after a reconnect. Reconnecting is only half the battle - your client also needs to resync. If your component joins a group or loads initial state, redo it when the connection comes back. The HubConnection.Reconnected event is the hook for this:
_connection.Reconnected += async connectionId =>
{
    // Re-join the document group (and re-fetch its current content)
    // now that we have a fresh connection.
    await _connection.InvokeAsync("JoinDocumentAsync", _documentId);
    _statusMessage = "Reconnected";
    StateHasChanged();
};

Without this step, reconnection makes the connection work again but leaves the UI out of sync - which is arguably worse than being disconnected, because everything looks fine.

Design hubs like the transient objects they are 🏗️

SignalR creates a new hub instance for every method invocation. That fact drives three of the most important server-side rules:

  • Don’t store per-connection or per-message state in hub instance fields. The instance is gone as soon as the method returns. If you need shared state, inject a service - that’s exactly what DocumentStore was for in part 3.
  • Inject dependencies instead of creating them. Hubs participate in the same dependency injection container as the rest of your ASP.NET Core app, so constructor injection just works. Keep hub methods thin and push real work into services you can test independently.
  • Use the connection, not the hub, to remember things. Context.ConnectionId identifies the current connection, and group membership is scoped to the connection and cleaned up automatically when it drops. Prefer those over anything you’d have to track yourself.

Respect async and the CancellationToken ⏱️

SignalR is built on async, and hub methods should be async Task or async Task<T> all the way through:

  • Never block with .Result, .Wait(), or Thread.Sleep in a hub method - you’ll tie up the hub’s thread and degrade the whole server. We’ll come back to this in the anti-patterns.
  • Honor the CancellationToken that SignalR passes to your hub methods. It fires when the client disconnects or cancels. In part 4 it was what stopped the price-feed producer and completed the channel - without it, background loops keep running forever after every disconnected client. Check it in long-running loops and pass it to the async calls you await.

Validate input and keep errors user-friendly 🛡️

Hub methods are just like any other public API surface - they take untrusted input from the client.

  • Validate arguments before you use them, and throw HubException for problems the client can do something about. HubException messages travel to the client intact, so they make great user-facing errors (remember the “A document ID is required.” case in part 3).
  • Don’t leak internals. Other exception types reach the client as a generic message unless you enable detailed errors, so let them. Stack traces and SQL errors belong in your logs, not in the browser.

Push from outside the hub with IHubContext 📨

The server has plenty of reasons to push data that don’t start with a client call - a background job finishes, a queue consumer gets a message, a business service raises an event. Inject IHubContext<THub> anywhere you need to broadcast. This was the backbone of the notification system in part 5:

public class ReportReadyNotifier(IHubContext<NotificationHub> hubContext)
{
    public Task NotifyAsync(string userId, string reportName) =>
        hubContext.Clients.Group(userId)
            .SendAsync("NotificationReceived",
                new AppNotification(Guid.NewGuid(),
                    $"Your report {reportName} is ready.",
                    DateTimeOffset.UtcNow));
}

This is how real notification systems work - the real-time push is just one leg of an event that also sends email or SMS.

Patterns 🧩

These are the recurring shapes we used across the series. They’re not frameworks - they’re arrangements of the same small set of SignalR primitives, and you’ll spot them in almost every well-built SignalR app.

  flowchart LR
    subgraph Client [Client / Blazor component]
        C[HubConnection]
    end
    subgraph Server [Server]
        H[Hub]
        S[Services]
    end
    C <-->|"1. Hub-per-concern"| H
    H -->|"2. Groups scope messages"| C
    S -->|"3. IHubContext pushes from outside"| H
    H -->|"4. Streams for continuous data"| C
  
  1. Hub per concern. Split hubs by feature area - ChatHub, DocumentHub, MarketDataHub, NotificationHub - rather than one giant hub that does everything. Each hub is small, focused, and easy to secure ([Authorize] per hub), and each one maps to its own URL. If a feature has a distinct purpose, it deserves its own hub.
  2. Group-based scoping. Almost every feature we built came down to: put related connections in a group, then send to the group. A document is a group, a chat room is a group, a user’s tabs are a group keyed by user ID. Groups give you a clean way to target the exact set of connections a message belongs to - and SignalR cleans up group membership when connections drop, so you don’t have to.
  3. Server-push via IHubContext. Notifications, job progress, and system events all share one shape: something on the server decides to push, then calls IHubContext to do it. This decouples the producers (background jobs, services) from the transport entirely - they don’t even know SignalR is involved.
  4. Streaming for continuous data. When the server has a flow of data rather than discrete events, return a ChannelReader<T> or IAsyncEnumerable<T> from a hub method and let the client await foreach over it. Pair it with the method’s CancellationToken so the producer stops when the client goes away. This is the pattern from part 4, and it’s the difference between “dashboards that update” and “dashboards that poll.”

Consider strongly-typed hubs 💬

One upgrade worth making early: instead of stringly calling SendAsync("ReceiveMessage", ...), define a client interface and use Hub<T>:

public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
    Task UserJoined(string userName);
}

public class ChatHub : Hub<IChatClient>
{
    public Task SendMessage(string user, string message) =>
        Clients.All.ReceiveMessage(user, message);   // compile-time checked
}

Now method names and signatures are checked by the compiler on both sides - you can’t misspell “ReceiveMessage” and discover it at runtime. The client still subscribes with connection.On<string, string>("ReceiveMessage", ...), but the server side stops being stringly-typed.

Keep a message contract between client and server 📦

Real-time messaging is distributed programming in miniature, and distributed systems hate drift. Share your message models (like AppNotification, StockPrice) between client and server code - in a Blazor solution, a shared class library is the natural home. When the payload changes shape, the compiler tells you everywhere that needs to update, instead of a subtle deserialization bug in production.

Anti-Patterns 🚫

Now the fun part - the mistakes we see most often. If you remember nothing else from this post, remember these.

Treating the hub as a singleton (state in instance fields) ❌

This is the most common SignalR mistake, and it’s a direct consequence of not realizing hubs are transient:

public class BadChatHub : Hub
{
    // BAD: this field is discarded when the method returns.
    // Two invocations never share it, so tracking state here is useless.
    private readonly List<string> _joinedRooms = [];
}

State that must survive across calls belongs in an injected service (singleton) or in a durable store. If you catch yourself adding an instance field to a hub to “remember” something about a connection, stop - use a service.

Broadcasting everything with Clients.All ❌

// BAD: every message goes to every connected client, forever.
await Clients.All.SendAsync("Message", data);

Clients.All is a blunt instrument. It’s fine for genuinely public broadcasts (announcements, global scoreboards), but the moment you have two documents, two rooms, or two users, it leaks data and wastes bandwidth. The group-based targeting we built in parts 3 and 5 exists precisely so user1 never sees user2’s notifications and design-doc never sees shared-doc edits. Default to the narrowest targeting that works - group, user, or connection ID.

Sending whole snapshots on every keystroke ❌

In the collaborative editor of part 3, we sent the entire document text with every keystroke - fine for a small demo, expensive as it grows. Sending whole snapshots repeatedly is a classic perf trap: the payload grows with the document, not with the change. As we noted in part 3, real collaborative editors send deltas or conflict-resolution operations (OT or CRDTs) instead. The general principle: send the change, not the state - unless the state is genuinely tiny.

One message per event during bursts ❌

// BAD: if 200 events fire in a burst, that's 200 messages.
foreach (var e in events)
{
    await hubContext.Clients.Group(groupId).SendAsync("EventHappened", e);
}

Rapid-fire events can flood the client with toasts or re-renders. Batch events that arrive in bursts (accumulate for a short window, then send them as one message) or coalesce them (send the latest value instead of every intermediate one). Your clients - and your UI - will thank you.

Registering handlers after StartAsync ❌

// BAD: the server may push "ReceiveMessage" before this line runs.
await _connection.StartAsync();
_connection.On<string, string>("ReceiveMessage", ...);

Handlers must be registered before the connection starts, or you can miss the very first messages. This is a one-line fix, but it’s the source of “it works sometimes” bugs that are horrible to chase down.

Forgetting to dispose connections ❌

Every component in the series ended with DisposeAsync() for a reason. A connection that’s never disposed keeps its keep-alive timers and server-side resources alive. In a Blazor app where components are created and destroyed constantly, that’s a slow-motion memory leak. If you create a HubConnection, you own its lifecycle - dispose it.

Ignoring reconnection (and resync) ❌

Network connections will drop - flaky Wi-Fi, laptop sleep, proxy timeouts. Two flavors of this anti-pattern:

  • Not enabling WithAutomaticReconnect() at all, leaving users stuck on a dead connection.
  • Enabling reconnection but never re-joining groups or reloading state on Reconnected, leaving the UI silently stale.

A connection that reconnects but hasn’t resynced looks more broken than one that just fails, because everything seems fine until it isn’t. Pair reconnection with a resync step (see the best-practices section above).

Blocking in async land ❌

// BAD: sync-over-async. This blocks the thread pool and can deadlock.
public string GetTime()
{
    return GetTimeAsync().Result;
}

// GOOD: let async flow through the whole call chain.
public async Task<string> GetTime() => await GetTimeAsync();

Blocking calls in hub methods (or in client handlers) tie up threads and can deadlock the connection. SignalR is async end-to-end - stay async the whole way. This also applies to client-side handlers: return Task.CompletedTask from a synchronous connection.On(...) handler rather than blocking inside it.

Not honoring the CancellationToken ❌

The CancellationToken passed to a hub method is your signal that the client is gone. Ignoring it means background producers keep running forever - a classic leak that only shows up under load, when “a few stray loops” become dozens per second. Check it, pass it down, and use it to stop loops and complete channels. (Part 4’s price stream is the model example.)

Using SignalR when it’s the wrong tool ❌

SignalR is great, but it’s not the only tool, and reach for it deliberately:

  • Server → client, one-way, doesn’t need to be instant? Consider Server-Sent Events. It’s simpler and works over plain HTTP.
  • Client polls every few minutes? SignalR buys you nothing - a plain HTTP fetch (or a background refresh) is simpler and more testable.
  • Request/response queries? A hub method that just returns a lookup you could do with an HTTP endpoint adds connection state and failure modes you don’t need. SignalR shines when the server pushes unsolicited data to the client - use it there, and let HTTP do the request/response.
  • Gaming or extreme latency? Raw WebSockets might be the better fit - SignalR’s transport negotiation, reconnection, and protocol add overhead that high-frequency gaming clients may not want.

A quick rule of thumb:

  flowchart TD
    A[Do I need the server to push to the client on its own?] -->|No| B[Use plain HTTP requests]
    A -->|Yes| C{Is the data a continuous stream?}
    C -->|Yes| D[SignalR streaming or SSE]
    C -->|No| E{Are messages targeted, live, and bidirectional?}
    E -->|Yes| F[SignalR hub - use groups / Clients.User]
    E -->|No| G[Consider SSE or polling]
  

Putting It All Together: A Checklist ✅

Before you ship your next SignalR feature, run this checklist:

Connection lifecycle

  • Handlers registered before StartAsync()
  • Automatic reconnection enabled (with a policy that fits your app)
  • Reconnected handler re-joins groups and resyncs state
  • Connections disposed in IAsyncDisposable components

Hub design

  • No state in hub instance fields - services hold shared state
  • Methods are async and never block
  • CancellationToken is honored in long-running work
  • Input is validated; HubException used for user-facing errors
  • Hubs protected with [Authorize] when the app has auth
  • Server-initiated pushes go through IHubContext

Targeting & data

  • Messages sent to groups/users/connections - not blanket Clients.All
  • Deltas, not full snapshots, for frequently-changing data
  • Bursty events batched or coalesced
  • Streaming uses channels/cancellation so producers stop cleanly

What’s Next? 🎬

This post is the capstone of the series, so let’s zoom out one more time. Everything we’ve covered - foundations, connections and hubs, collaboration, streaming, notifications, and now these practices and patterns - works on a single server. The natural next steps for production are:

  • Authentication: add login, protect your hubs with [Authorize], and switch from hand-rolled user groups to Clients.User() with the built-in user mapping.
  • Scale-out: move to the Azure SignalR Service or a Redis backplane so messages and groups work across multiple servers, and put shared state in a real store instead of server memory.
  • Testing: verify your hubs with unit tests (hub methods are just classes with injected services) and end-to-end with xUnit or a browser automation tool like Playwright.

Real-time features are one of the fastest ways to make an app feel alive. Get the fundamentals right, lean on the patterns, and avoid the anti-patterns, and you’ll be building experiences like the ones we made throughout this series with confidence. Happy real-time building!

Resources 📚