Understanding SignalR Connections & Hubs

August 11, 2026Understanding SignalR Connections & Hubs
Sarah Dutkiewicz

Sarah Dutkiewicz, Senior Trainer

Welcome back to our SignalR for Blazor series! In the first post, we covered what real-time communication is, why SignalR pairs so naturally with Blazor, and we stood up a tiny ChatHub that could broadcast messages to everyone connected. In this post, we’re going to zoom in on the two pieces that make that magic happen: connections and hubs. We’ll look at what a hub really is, how clients connect to one, how data flows back and forth, and we’ll wrap up with a simple, well-commented example of a client calling a hub method and getting a response back.

Hubs โ€“ The Heart of Communication ๐Ÿ’ฌ

If connections are the roads between the client and the server, hubs are the town square where everyone meets. A hub is a server-side class that acts as a proxy between clients and your server-side code. Clients connect to a hub at a specific URL (remember app.MapHub<ChatHub>("/chathub") from the last post), and once they’re connected, two things can happen:

  • The client can call methods on the hub, passing data along as arguments.
  • The hub can call methods on connected clients, pushing data out to one, some, or all of them at once.

That sounds simple, but it’s a big deal. In a normal HTTP request, the client asks and the server answers. A hub flips the relationship so the server can start the conversation whenever it wants. Think of a hub like a conference-call bridge: everyone dials into the same bridge, and when anyone speaks, the bridge decides who gets to hear it.

Here are a few important details about how hubs work:

  • Hubs are transient. SignalR creates a new instance of your hub class for every method invocation. This means you should not rely on instance fields to hold state between calls. If you need to remember something about a client, use the connection’s storage or a service you inject.
  • Clients gives you access to connected clients. This is how the hub pushes messages out. You can target everyone (Clients.All), just the caller (Clients.Caller), everyone except the caller (Clients.Others), a specific client by connection ID (Clients.Client(id)), or a group (Clients.Group(name)). We’ll look at these in more detail below and in a future post.
  • Context gives you information about the current call, most notably Context.ConnectionId, the unique ID of the connection that made the call.
  • Groups lets you add and remove connections from named groups โ€” a handy way to target a set of related connections, like “all users in room #42”.
  • Hubs support dependency injection. Just like any other service in ASP.NET Core, you can inject dependencies through the constructor. You can also protect hubs with [Authorize] just like controllers.

The key takeaway: a hub is the server-side object your clients talk to, and it’s the coordination point for everything real-time in your app.

Clients & Connections ๐Ÿ”Œ

Before a client can talk to a hub, it needs a connection. A connection is a persistent link between a single client and the server, and every connection gets a unique connection ID (Context.ConnectionId on the server). While that connection is open, messages can flow in both directions at any time โ€” no need to re-request each time.

In the Blazor world, you’ll typically be working with two kinds of clients:

  • Blazor Server: SignalR is actually the backbone of Blazor Server itself. When you use interactive server components, Blazor is already maintaining a SignalR connection behind the scenes to send UI updates to the browser. In addition to that built-in connection, your components can create their own hub connections when you need real-time features beyond what Blazor gives you out of the box.
  • Blazor WebAssembly: Blazor WebAssembly runs in the browser, so it doesn’t have a built-in server connection. Instead, your app uses the SignalR .NET client to open connections to hubs, exactly like any other client.

Building a connection from a .NET client (which covers both Blazor hosting models) looks like this:

HubConnection connection = new HubConnectionBuilder()
    .WithUrl("/chathub")
    .WithAutomaticReconnect()
    .Build();

await connection.StartAsync();

A few things worth understanding about connection management:

  • StartAsync() opens the connection. Until you call it, nothing flows. HubConnection is your client-side object that represents and manages the connection.
  • Automatic reconnection. WithAutomaticReconnect() tells the client to try reconnecting on its own when the connection drops. It uses a default retry schedule (a few attempts with increasing delays), and you can customize it with your own retry policy if you want. It’s one of those features that quietly saves users from frustrating “please refresh” moments.
  • Keep-alives. The server periodically sends keep-alive pings to verify connections are still alive, and clients have a timeout window for those pings. If the client doesn’t hear from the server in time, it treats the connection as dead and starts its reconnect logic.
  • Register handlers before you start. You subscribe to the messages the server can push using connection.On<T>(...), and you’ll want those handlers registered before calling StartAsync() so you don’t miss anything arriving right after the connection opens.

One connection is one client. If you open your app in two browser tabs, you have two connections (and two connection IDs). That distinction becomes really important when you want to send a message to one client rather than everyone โ€” which is exactly what the next section is about.

Methods โ€“ Sending and Receiving Data ๐Ÿ“จ

Connections carry messages in both directions, and SignalR treats the two directions slightly differently.

Client โ†’ Server: calling a hub method

From the client, you call a hub method by name, passing any arguments:

string reply = await connection.InvokeAsync<string>("EchoAsync", "Hello, server!");

On the server, the hub method is just a normal C# method:

public async Task<string> EchoAsync(string message)
{
    return $"You said: {message}";
}

InvokeAsync behaves like any other async method call: the client sends the invocation, the server runs the method, and the return value comes back to the client. The method can return a plain value or a Task<T> โ€” SignalR handles the await either way.

There’s also SendAsync, which is fire-and-forget: the client sends the invocation and doesn’t wait for a response. That’s a good fit when the client doesn’t care about the result (like the SendMessage call in our part 1 chat example).

Server โ†’ Client: calling methods on clients

From the hub, you push data out using the Clients property:

await Clients.All.SendAsync("ReceiveMessage", user, message);

This tells every connected client to run a method named ReceiveMessage with the given arguments. On the client side, that method is registered as a handler:

connection.On<string, string>("ReceiveMessage", (user, message) =>
{
    // Update the UI with the new message
});

And as we touched on above, you can aim that push at different audiences:

await Clients.Caller.SendAsync("PrivateResponse", data);        // just the caller
await Clients.Others.SendAsync("SomeoneElseActed", data);       // everyone but the caller
await Clients.Client(connectionId).SendAsync("Targeted", data); // one specific client
await Clients.Group("room-42").SendAsync("RoomUpdate", data);   // a group

A note on async/await โฑ๏ธ

SignalR is built around asynchronous programming, and hub methods should be async โ€” typically Task or Task<T>. Avoid blocking calls and async void; let SignalR’s await flow do the work. Hub methods can also accept a CancellationToken if you want to react to the client disconnecting mid-call.

One subtle detail: SignalR matches method names case-insensitively on both sides, so "echoauth" and "EchoAsync" resolve to the same method. Pick a naming convention and be consistent, and it’ll feel just like calling a method on any other object โ€” because that’s exactly what you’re doing.

SignalR Server Architecture Overview (Simplified) ๐Ÿ—๏ธ

When a client connects to a hub, a lot is happening on the server even though it feels effortless. Here’s the simplified picture:

  flowchart TD
    A[Client connects to the hub] --> B[Server registers the connection and assigns a ConnectionId]
    B --> C[Client invokes a hub method]
    C --> D[SignalR creates a hub instance and runs the method]
    D --> E[Hub method runs, using Context and Clients]
    E --> F[Return value is sent back to the caller]
    E --> G{Clients targeting}
    G -->|All| H[Push to every connected client]
    G -->|Group| I[Push to the group's connections]
    G -->|ConnectionId| J[Push to that one client]
  
  1. Managing connections. The server keeps track of every active connection and its unique connection ID. When a connection opens or closes, the server knows (OnConnectedAsync and OnDisconnectedAsync on the hub let you react to those events).
  2. Routing invocations. When a client calls a hub method, SignalR routes the request to the right hub class, creates an instance, runs the method, and sends the result back over that client’s connection.
  3. Coordinating broadcasts. When a hub sends to Clients.All, Clients.Group, or a specific client, SignalR resolves which connections are the targets, serializes the message, and pushes it out over each connection’s transport.
  4. Groups. SignalR keeps lightweight sets of connection IDs on the server, which is how groups can target many connections with a single call.

For a single server, all of this coordination lives in memory on that server, which is simple and fast. When your app grows to multiple servers, that coordination needs to be shared โ€” which is where backplanes come in. The Azure SignalR Service or a Redis backplane let servers share connection information so a message sent on one server still reaches a client connected to another. That’s a scale-out topic we’ll save for a future post, but it’s good to know the option exists.

Hands-on Example - Simple Hub Call ๐Ÿงช

Let’s put all of this together with a small example. We’ll build a hub with one method that accepts data from a client, sends a response back to the caller, and also notifies other connected clients that something happened โ€” demonstrating the client-to-server and server-to-client flows in one go. Here’s the flow we’re about to build:

  sequenceDiagram
    participant ClientA as Client A (this browser tab)
    participant Hub as TimeHub (server)
    participant ClientB as Client B (another browser tab)
    ClientA->>Hub: InvokeAsync("GetServerTimeAsync", clientTime)
    activate Hub
    Hub-->>ClientB: SendAsync("ClientRequestedTime", callerId, clientTime)
    Hub-->>ClientA: return serverTime
    deactivate Hub
  

First, the hub. Create a file called TimeHub.cs:

using Microsoft.AspNetCore.SignalR;

namespace SignalRForBlazorDemo.Hubs;

public class TimeHub : Hub
{
    // A client calls this method and awaits the response, just like any
    // other async method call. The client passes its own local time so the
    // server can respond with how far apart the two clocks are.
    public async Task<DateTimeOffset> GetServerTimeAsync(DateTimeOffset clientTime)
    {
        // Context.ConnectionId identifies the client that made this call.
        string callerId = Context.ConnectionId;

        // Let every OTHER connected client know someone just asked for the time.
        await Clients.Others.SendAsync("ClientRequestedTime", callerId, clientTime);

        // Return a response that only the caller will receive.
        return DateTimeOffset.UtcNow;
    }
}

Register the hub in Program.cs, right next to the one from part 1:

app.MapHub<ChatHub>("/chathub");
app.MapHub<TimeHub>("/timehub");

Now let’s create a component that connects to the hub. Add a file called ServerTime.razor:

@using Microsoft.AspNetCore.SignalR.Client
@implements IAsyncDisposable

<button @onclick="GetServerTimeAsync" disabled="@_isBusy">
    Get Server Time
</button>

<p>Server time: @_serverTime</p>

@code {
    private HubConnection? _hubConnection;
    private DateTimeOffset? _serverTime;
    private bool _isBusy;

    protected override async Task OnInitializedAsync()
    {
        // Build the connection, pointing at our TimeHub endpoint.
        _hubConnection = new HubConnectionBuilder()
            .WithUrl("/timehub")
            .WithAutomaticReconnect()
            .Build();

        // Register the handler for the server-to-client event BEFORE starting
        // the connection so we don't miss any messages that arrive right away.
        _hubConnection.On<string, DateTimeOffset>("ClientRequestedTime",
            (callerId, clientTime) =>
            {
                // We could update the UI here; for this demo we'll just log.
                Console.WriteLine($"Client {callerId} asked for the time (their clock said {clientTime:HH:mm:ss}).");
                return Task.CompletedTask;
            });

        // Open the connection to the server.
        await _hubConnection.StartAsync();
    }

    private async Task GetServerTimeAsync()
    {
        if (_hubConnection is null)
        {
            return;
        }

        _isBusy = true;

        // Call the hub method and await the server's response.
        _serverTime = await _hubConnection.InvokeAsync<DateTimeOffset>(
            "GetServerTimeAsync",
            DateTimeOffset.Now);

        _isBusy = false;
    }

    public async ValueTask DisposeAsync()
    {
        // Always clean up the connection when the component goes away.
        if (_hubConnection is not null)
        {
            await _hubConnection.DisposeAsync();
        }
    }
}

Here’s what happens when the button is clicked:

  1. The component calls InvokeAsync("GetServerTimeAsync", ...), sending the client’s local time to the hub.
  2. On the server, SignalR creates a TimeHub instance, runs GetServerTimeAsync, and returns the server’s UTC time.
  3. The hub also pushes a ClientRequestedTime event to other connected clients.
  4. Back on the caller’s side, the awaited InvokeAsync resolves with the server time, and the component displays it.

Two clients connected in two tabs, click the button in one tab, and you’ll see the response in that tab while the console in the other tab (and any other connected client) shows the notification. Client-to-server and server-to-client, working together!

What’s Next? ๐ŸŽฌ

In this post, we looked at what hubs are, how connections work, and how messages travel in both directions. We used Clients.All and Clients.Others, and we mentioned groups and connection IDs as ways to target specific clients. In the next post, we’ll go deeper into targeting โ€” groups, connection IDs, and how to send messages to exactly the right clients, which is the foundation for building collaborative features. After that, we’ll start building out the real-world use cases we teased in the first post.

Resources ๐Ÿ“š