Skip to content

SignalR

Key Points

  • SignalR is ASP.NET Core's real-time bidirectional comms library. Picks the best transport: WebSockets (preferred), Server-Sent Events, Long Polling.
  • Hubs are the abstraction. Server methods callable from client; server can push to clients via IHubContext.
  • Scaling: single-instance OK for small apps. Multi-instance needs a backplane (Redis, Azure SignalR Service) so clients on different servers see each other.
  • Azure SignalR Service handles the backplane and connection scaling — server stays light, just emits messages. Best for serverless / massive scale.
  • MessagePack vs JSON — MessagePack is faster, smaller; default for new projects.

Concepts (deep dive)

Hubs

public class ChatHub : Hub
{
    public async Task SendMessage(string user, string msg)
        => await Clients.All.SendAsync("ReceiveMessage", user, msg);

    public override async Task OnConnectedAsync()
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, "everyone");
        await base.OnConnectedAsync();
    }
}

builder.Services.AddSignalR();
app.MapHub<ChatHub>("/chat");

Client (JS):

const conn = new signalR.HubConnectionBuilder().withUrl("/chat").build();
conn.on("ReceiveMessage", (user, msg) => console.log(user, msg));
await conn.start();
await conn.invoke("SendMessage", "Alice", "Hi");

Strongly-typed hubs

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

public class ChatHub : Hub<IChatClient>
{
    public async Task SendMessage(string user, string msg)
        => await Clients.All.ReceiveMessage(user, msg);   // strongly typed
}

Compile-time checking on method names.

Pushing from outside the hub

public class NotificationService(IHubContext<ChatHub, IChatClient> hub)
{
    public Task Notify(string user, string msg)
        => hub.Clients.User(user).ReceiveMessage("system", msg);
}

Inject IHubContext to push from controllers, background services.

Targeting clients

Clients.All
Clients.Caller
Clients.Others
Clients.User(userId)              // requires UserIdProvider
Clients.Users(new[] { id1, id2 })
Clients.Group("groupName")
Clients.Client(connectionId)

Groups

await Groups.AddToGroupAsync(Context.ConnectionId, "room-42");
await Clients.Group("room-42").SendAsync("Message", payload);

Group membership lost on disconnect; rejoin on reconnect (in OnConnectedAsync).

User identification

public class CustomUserIdProvider : IUserIdProvider
{
    public string? GetUserId(HubConnectionContext c) => c.User?.FindFirst("sub")?.Value;
}

builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();

By default, ClaimTypes.NameIdentifier claim. Customize for JWT sub etc.

Authentication

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(o =>
    {
        o.Events = new JwtBearerEvents
        {
            OnMessageReceived = ctx =>
            {
                if (ctx.Request.Path.StartsWithSegments("/chat") && ctx.Request.Query.TryGetValue("access_token", out var token))
                    ctx.Token = token;
                return Task.CompletedTask;
            }
        };
    });

[Authorize]
public class ChatHub : Hub { /* ... */ }

WebSocket connect URLs can pass token in query (browsers can't set Authorization header on WS handshake).

Scaling: backplane

Single instance: works.

Multi-instance:

Client → Server A: connection
Client → Server B: connection
Server A's Clients.All → only A's clients (not B's!)

Backplane fixes this — broadcasts published messages to all servers.

// Redis backplane
builder.Services.AddSignalR().AddStackExchangeRedis("redis:6379");

// Azure SignalR Service
builder.Services.AddSignalR().AddAzureSignalR("Endpoint=https://...");

Azure SignalR Service

Managed; offloads connection scaling. Your app emits messages; Azure SignalR fans out.

Client → Azure SignalR ← Your app (just publish messages)

Massive scale (>100K connections per unit). No state in your app.

Streaming

Hub returning IAsyncEnumerable<T> streams to client:

public async IAsyncEnumerable<int> Counter(int delay, [EnumeratorCancellation] CancellationToken ct)
{
    for (int i = 0; ; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(delay, ct);
        yield return i;
    }
}
conn.stream("Counter", 1000).subscribe({
    next: v => console.log(v),
    complete: () => console.log("done")
});

Backpressure

builder.Services.AddSignalR(o =>
{
    o.MaximumReceiveMessageSize = 32 * 1024;
    o.StreamBufferCapacity = 10;
});

Bounded buffer for streams. Backpressure when client slow.

Connection lifecycle

  • OnConnectedAsync — joined.
  • OnDisconnectedAsync(Exception?) — left (graceful or error).
  • Implement reconnect logic on client.

Auto-reconnect (client)

const conn = new signalR.HubConnectionBuilder()
    .withUrl("/chat")
    .withAutomaticReconnect([0, 2000, 10000, 30000])
    .build();

Reconnect attempts with backoff.

When SignalR vs raw WebSockets

  • SignalR: high-level abstraction; transport fallback; multi-server scaling; auth integration.
  • Raw WebSockets: when you need binary protocol control or SignalR overhead is too much.

For 95% of real-time .NET, SignalR.

Performance

WebSocket: lowest overhead. SSE: server→client only; HTTP. Long polling: fallback; expensive.

MessagePack protocol (.AddMessagePackProtocol()) — smaller, faster than JSON.

Heartbeats

o.KeepAliveInterval = TimeSpan.FromSeconds(15);
o.ClientTimeoutInterval = TimeSpan.FromSeconds(30);

Detect dead connections.

Common issues

  • Sticky sessions: WebSocket connections need to stay on the same server. With LB, use sticky cookies or backplane.
  • Groups not persisting — explicitly rejoin in OnConnectedAsync.
  • Static IHubContext for background jobs — bad; inject via DI.

Code: correct vs wrong

❌ Wrong: backplane missing in multi-instance

builder.Services.AddSignalR();   // single-instance only

✅ Correct: Redis or Azure

builder.Services.AddSignalR().AddStackExchangeRedis("redis:6379");

❌ Wrong: pushing from controller without IHubContext

var hub = new ChatHub();   // can't instantiate; not a hub instance
hub.Clients.All.SendAsync(...);   // null

✅ Correct: inject IHubContext

public class C(IHubContext<ChatHub, IChatClient> hub) { /* ... */ }

Design patterns for this topic

Pattern 1 — "Strongly-typed hubs"

  • Intent: compile-time client method names.

Pattern 2 — "Backplane for scale-out"

  • Intent: Redis or Azure SignalR Service.

Pattern 3 — "Streaming hub methods"

  • Intent: push series of values.

Pattern 4 — "MessagePack protocol"

  • Intent: smaller; faster than JSON.

Pattern 5 — "Auto-reconnect on client"

  • Intent: UX through transient outages.

Pros & cons / trade-offs

Aspect Pros Cons
SignalR Easy API; transport fallback Some overhead
Raw WebSockets Lean Manual scaling; auth
Azure SignalR Massive scale Vendor cost
Redis backplane Cheap Operational

When to use / when to avoid

  • Use for chat, dashboards, notifications, collaborative features.
  • Use Azure SignalR for serverless / huge scale.
  • Avoid for simple request-response.

Interview Q&A

Q1. SignalR transports? WebSockets (preferred), Server-Sent Events, Long Polling. Auto-fallback.

Q2. Why a backplane? Multi-instance: Server A's broadcast doesn't reach Server B's clients. Backplane synchronizes.

Q3. Backplane options? Redis, Azure SignalR Service, SQL Server (deprecated).

Q4. Strongly-typed hub? Hub<T> where T is a client interface. Server calls Clients.All.MethodName(...) — strongly typed.

Q5. Push from outside hub? Inject IHubContext<THub> or IHubContext<THub, TClient> and call.

Q6. JWT for SignalR? WS handshake can't set headers; pass token via query string; JwtBearer's OnMessageReceived reads it.

Q7. Groups persist across reconnect? No. Rejoin in OnConnectedAsync.

Q8. Streaming hub method? Return IAsyncEnumerable<T>. Client subscribes via .stream(...).

Q9. MessagePack vs JSON? MessagePack: binary; smaller; faster. Recommended.

Q10. Sticky sessions necessary? WebSocket: yes (connection bound to one server). With backplane, broadcasting works across; but the WS itself is sticky.

Q11. Azure SignalR Service benefits? Managed connection scaling; offloads from your app. Pay per connection-second.

Q12. KeepAlive vs ClientTimeout? KeepAlive: server pings client. ClientTimeout: max silence before considered dead.


Gotchas / common mistakes

  • ⚠️ No backplane in multi-instance — broadcasts incomplete.
  • ⚠️ Token in Authorization header for WS — browsers can't set; use query.
  • ⚠️ Groups assumed persistent — rejoin on reconnect.
  • ⚠️ Static IHubContext — wrong lifetime.
  • ⚠️ Default JSON without compression — bandwidth.

Further reading