Signalr

9 min read
Rapid overview

SignalR - Real-Time Web Communication in .NET

What Is SignalR

Q: What is SignalR and what problem does it solve?

A: SignalR is an ASP.NET Core library that provides real-time, bidirectional communication between server and client. Traditional HTTP is request-response -- the client asks, the server answers. SignalR lets the server push data to clients at any time, which is essential for live dashboards, chat, notifications, collaborative editing, and multiplayer games.

SignalR abstracts transport selection, connection management, reconnection, and serialization. The developer writes one API; SignalR picks the best transport automatically.

// A minimal real-time endpoint
public class ChatHub : Hub
{
    public async Task SendMessage(string user, string message)
    {
        await Clients.All.SendAsync("ReceiveMessage", user, message);
    }
}

Hubs and Hub Methods

Q: What is a Hub and how do you define one?

A: A Hub is the central server-side class in SignalR. It defines methods that clients can invoke (client-to-server RPC) and provides infrastructure for calling methods on connected clients (server-to-client RPC). Hubs support constructor injection from the DI container.

public class OrderHub : Hub
{
    private readonly IOrderService _orderService;

    public OrderHub(IOrderService orderService) => _orderService = orderService;

    // Client calls: connection.invoke("PlaceOrder", orderDto)
    public async Task<OrderConfirmation> PlaceOrder(OrderDto order)
    {
        var result = await _orderService.ProcessAsync(order);
        await Clients.Group(order.Desk).SendAsync("OrderPlaced", result);
        return result; // returned to calling client
    }
}
Q: What key properties are available inside a Hub method?

A: Context.ConnectionId (unique per connection), Context.User (the ClaimsPrincipal), Context.UserIdentifier (the NameIdentifier claim), Context.Items (per-connection dictionary), Context.GetHttpContext() (underlying HTTP context), Clients (send messages to clients), and Groups (manage group membership).

Transport Negotiation

  1. WebSockets -- Full-duplex, single TCP connection. Best performance and lowest latency.
  2. Server-Sent Events (SSE) -- Unidirectional server-to-client over HTTP. Decent fallback but only server-to-client push.
  3. Long Polling -- Client repeatedly opens HTTP requests waiting for data. Last resort; highest overhead.
Q: What transports does SignalR support and how does it choose one?

A: SignalR supports three transports in priority order:

During the negotiation handshake (/negotiate endpoint), the client and server agree on the best transport both support. You can restrict transports explicitly:

app.MapHub<ChatHub>("/chat", options =>
{
    options.Transports = HttpTransportType.WebSockets; // WebSockets only
});
Q: Can you skip negotiation entirely?

A: Yes. If you know both client and server support WebSockets, configure SkipNegotiation = true on the client to connect directly, saving one round trip.

var connection = new HubConnectionBuilder()
    .WithUrl("https://api.example.com/chat", options =>
    {
        options.Transports = HttpTransportType.WebSockets;
        options.SkipNegotiation = true;
    })
    .Build();

Client-to-Server and Server-to-Client

Q: How does client-to-server communication work?

A: The client calls connection.InvokeAsync("MethodName", args) (expects a return value) or connection.SendAsync("MethodName", args) (fire-and-forget). The server hub method executes and can optionally return a result.

Q: How does server-to-client communication work?

A: Inside a hub method, use the Clients property to target recipients. Outside a hub, inject IHubContext<THub>.

// Inside a hub
await Clients.All.SendAsync("Notify", message);           // everyone
await Clients.Caller.SendAsync("Ack", id);                // calling client only
await Clients.Others.SendAsync("UserJoined", userName);    // everyone except caller
await Clients.Client(connectionId).SendAsync("Direct", m); // specific connection
await Clients.User(userId).SendAsync("Alert", msg);        // all connections of a user

// Outside a hub (e.g., background service or controller)
public class PriceService
{
    private readonly IHubContext<StockHub> _hub;
    public PriceService(IHubContext<StockHub> hub) => _hub = hub;

    public async Task BroadcastPrice(StockPrice price)
    {
        await _hub.Clients.Group($"symbol:{price.Symbol}")
            .SendAsync("PriceUpdate", price);
    }
}

Groups

Q: What are SignalR groups and how do you use them?

A: Groups are named collections of connections. A connection can belong to multiple groups. Groups are managed inside hub methods via Groups.AddToGroupAsync and Groups.RemoveFromGroupAsync. Group membership is automatically cleaned up when a connection disconnects.

public class TradingHub : Hub
{
    public async Task JoinDesk(string desk)
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, desk);
        await Clients.Group(desk).SendAsync("Joined", Context.User?.Identity?.Name);
    }

    public async Task LeaveDesk(string desk)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, desk);
    }

    public async Task BroadcastToDesk(string desk, string message)
    {
        await Clients.Group(desk).SendAsync("DeskMessage", message);
    }
}
Q: Are groups persisted across reconnections?

A: No. Groups are in-memory and tied to the connection ID. When a client reconnects, it gets a new connection ID and must rejoin its groups. Handle this in OnConnectedAsync.

Strongly-Typed Hubs

Q: What is a strongly-typed hub and why use it?

A: A strongly-typed hub replaces magic strings in SendAsync("MethodName") with a typed interface. This gives compile-time safety and IntelliSense for server-to-client calls.

// Define the client contract
public interface IChatClient
{
    Task ReceiveMessage(string user, string message);
    Task UserJoined(string user);
    Task UserLeft(string user);
}

// Hub uses the interface instead of dynamic SendAsync
public class ChatHub : Hub<IChatClient>
{
    public async Task Send(string user, string message)
    {
        await Clients.All.ReceiveMessage(user, message);  // compile-time checked
        // await Clients.All.SendAsync("ReceiveMessage")   // NOT available -- magic strings eliminated
    }

    public override async Task OnConnectedAsync()
    {
        await Clients.Others.UserJoined(Context.User?.Identity?.Name ?? "Anonymous");
    }
}

Authentication in SignalR

Q: How do you authenticate SignalR connections?

A: SignalR uses the same authentication middleware as ASP.NET Core. For WebSockets, the token is typically sent as a query string parameter because browsers cannot set headers on WebSocket connections.

// Server configuration
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Events = new JwtBearerEvents
        {
            OnMessageReceived = context =>
            {
                // Read token from query string for SignalR
                var token = context.Request.Query["access_token"];
                var path = context.HttpContext.Request.Path;
                if (!string.IsNullOrEmpty(token) && path.StartsWithSegments("/hubs"))
                {
                    context.Token = token;
                }
                return Task.CompletedTask;
            }
        };
    });

// Protect the hub
[Authorize]
public class SecureHub : Hub
{
    public string WhoAmI() => Context.User?.Identity?.Name ?? "unknown";
}
// Client sends token
var connection = new HubConnectionBuilder()
    .WithUrl("https://api.example.com/hubs/secure", options =>
    {
        options.AccessTokenProvider = () => Task.FromResult(_tokenService.GetToken());
    })
    .Build();

Scaling with Redis Backplane

Q: How do you scale SignalR across multiple servers?

A: A single server only knows about its own connections. To broadcast across a server farm, use a backplane -- a shared message bus. The most common is Redis. When server A sends a message to a group, the Redis backplane forwards it to servers B and C, which deliver it to their local connections.

// Program.cs
builder.Services.AddSignalR()
    .AddStackExchangeRedis("localhost:6379", options =>
    {
        options.Configuration.ChannelPrefix = RedisChannel.Literal("MyApp");
    });
Q: Are there alternatives to Redis for the backplane?

A: Yes. Azure SignalR Service is a fully managed option that offloads connection management entirely. SQL Server and custom ISignalRServerBuilder implementations also exist. For Kubernetes, you can use sticky sessions (but this limits scaling) or a distributed backplane.

Streaming

Q: How does SignalR support server-to-client streaming?

A: A hub method that returns IAsyncEnumerable<T> or ChannelReader<T> streams data to the client. Items are sent as they are yielded.

public class DataHub : Hub
{
    // IAsyncEnumerable approach (preferred)
    public async IAsyncEnumerable<int> Counter(
        int count, int delayMs,
        [EnumeratorCancellation] CancellationToken ct)
    {
        for (var i = 0; i < count; i++)
        {
            ct.ThrowIfCancellationRequested();
            yield return i;
            await Task.Delay(delayMs, ct);
        }
    }
}
Q: Does SignalR support client-to-server streaming?

A: Yes. The hub method accepts IAsyncEnumerable<T> or ChannelReader<T> as a parameter. The client pushes items and the server consumes them as they arrive.

// Server
public async Task UploadStream(IAsyncEnumerable<SensorReading> stream)
{
    await foreach (var reading in stream)
    {
        await _repository.SaveAsync(reading);
    }
}

Connection Lifecycle

Q: What lifecycle events does a Hub expose?

A: Override OnConnectedAsync and OnDisconnectedAsync to hook into connection events. These are the places to track online users, join default groups, or clean up resources.

public class PresenceHub : Hub
{
    private readonly IPresenceTracker _tracker;

    public PresenceHub(IPresenceTracker tracker) => _tracker = tracker;

    public override async Task OnConnectedAsync()
    {
        var user = Context.User?.Identity?.Name ?? "anonymous";
        await _tracker.UserConnected(user, Context.ConnectionId);
        await Clients.Others.SendAsync("UserOnline", user);
        await base.OnConnectedAsync();
    }

    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        var user = Context.User?.Identity?.Name ?? "anonymous";
        await _tracker.UserDisconnected(user, Context.ConnectionId);
        await Clients.Others.SendAsync("UserOffline", user);
        await base.OnDisconnectedAsync(exception);
    }
}
Q: How does automatic reconnection work?

A: The .NET client supports WithAutomaticReconnect(). It attempts reconnection with configurable delays. During reconnection, Reconnecting and Reconnected events fire on the client. The connection gets a new ConnectionId on reconnect, so group memberships must be reestablished.

var connection = new HubConnectionBuilder()
    .WithUrl("https://api.example.com/hubs/data")
    .WithAutomaticReconnect(new[] { TimeSpan.Zero, TimeSpan.FromSeconds(2),
        TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30) })
    .Build();

connection.Reconnecting += error => { Console.WriteLine("Reconnecting..."); return Task.CompletedTask; };
connection.Reconnected += newId => { Console.WriteLine($"Reconnected: {newId}"); return Task.CompletedTask; };

MessagePack Protocol

Q: What is MessagePack and why use it with SignalR?

A: MessagePack is a binary serialization format -- smaller and faster than JSON. SignalR supports it as an alternative hub protocol. It reduces payload size and serialization time, which matters for high-throughput scenarios like live financial data.

// Server
builder.Services.AddSignalR()
    .AddMessagePackProtocol();

// .NET Client
var connection = new HubConnectionBuilder()
    .WithUrl("https://api.example.com/hubs/data")
    .AddMessagePackProtocol()
    .Build();

Tradeoff: MessagePack is not human-readable, making debugging harder. JSON is better for development; MessagePack for production throughput.

Testing Hubs

Q: How do you unit test a SignalR hub?

A: Mock the IHubCallerClients, IGroupManager, and HubCallerContext interfaces. Inject them via the hub's properties, then call hub methods directly.

[Fact]
public async Task SendMessage_broadcasts_to_all_clients()
{
    // Arrange
    var mockClients = new Mock<IHubCallerClients<IChatClient>>();
    var mockAllClients = new Mock<IChatClient>();
    mockClients.Setup(c => c.All).Returns(mockAllClients.Object);

    var hub = new ChatHub
    {
        Clients = mockClients.Object
    };

    // Act
    await hub.Send("alice", "hello");

    // Assert
    mockAllClients.Verify(c => c.ReceiveMessage("alice", "hello"), Times.Once);
}
Q: How do you integration test SignalR?

A: Use WebApplicationFactory to spin up a test server and connect a real HubConnection to it.

[Fact]
public async Task Chat_end_to_end()
{
    await using var app = new WebApplicationFactory<Program>();
    var server = app.Server;

    var connection = new HubConnectionBuilder()
        .WithUrl($"{server.BaseAddress}hubs/chat", o =>
        {
            o.HttpMessageHandlerFactory = _ => server.CreateHandler();
        })
        .Build();

    var received = new TaskCompletionSource<string>();
    connection.On<string, string>("ReceiveMessage", (user, msg) =>
    {
        received.SetResult(msg);
    });

    await connection.StartAsync();
    await connection.InvokeAsync("SendMessage", "tester", "hello");

    var message = await received.Task.WaitAsync(TimeSpan.FromSeconds(5));
    Assert.Equal("hello", message);
}

Questions & Answers

Q1: Why would you choose SignalR over raw WebSockets?

A: SignalR adds automatic transport fallback, reconnection, hub routing, group management, serialization, and integration with ASP.NET Core auth/DI. Raw WebSockets give you a byte stream and nothing else -- you must build all of that yourself.

Q2: What happens when a connection drops mid-stream?

A: The server's OnDisconnectedAsync fires. If the client has WithAutomaticReconnect, it will attempt to reconnect. Any active streaming hub method receives a cancellation signal via the CancellationToken. Buffered messages in transit are lost unless you implement acknowledgment at the application level.

Q3: How do you send a message to a specific user across multiple connections?

A: Use Clients.User(userId). SignalR resolves the user ID from the NameIdentifier claim and delivers the message to all of that user's active connections. This works across servers when using a backplane.

Q4: Can SignalR handle binary data?

A: Yes. With the MessagePack protocol, binary data is natively supported. With JSON, you must Base64-encode binary payloads. For large binary transfers, consider streaming via IAsyncEnumerable<byte[]> or using a separate upload endpoint.

Q5: What is the maximum number of connections a single SignalR server can handle?

A: With WebSockets, a server can handle tens of thousands of concurrent connections (limited by OS socket limits and memory). Azure SignalR Service scales to hundreds of thousands. Long Polling is much more expensive per connection due to repeated HTTP requests.

Q6: How do you handle hub method exceptions?

A: Unhandled exceptions in hub methods are caught by SignalR, logged, and sent to the calling client as a HubException. In production, enable EnableDetailedErrors only in development to avoid leaking stack traces.

builder.Services.AddSignalR(options =>
{
    options.EnableDetailedErrors = builder.Environment.IsDevelopment();
});