Skip to content
ExponaBLOG
KAFKA & WEBSOCKETS · DAY 076 / 100

Real-time in .NET with SignalR: best practices explained

Hubs, groups and scale-out.

Expona Admin6 min read

Real-time in .NET with SignalR brings instant, bidirectional communication to your applications without the complexity of managing raw WebSocket plumbing yourself. Whether you're building live dashboards, collaborative editing features, or multiplayer experiences, SignalR abstracts away transport details and scaling headaches. By day 76 of this series, you've mastered Kafka's event streaming—now it's time to learn how to push those events to connected clients instantly.

Hubs — Server methods clients call and client methods servers call

At the heart of SignalR is the Hub: a server-side class that clients connect to and invoke methods on. Unlike traditional HTTP, the connection is persistent and bidirectional. Your server can call methods on clients, and clients can invoke server methods. This symmetry is what makes real-time interactions feel natural.

Let me show you a practical example. Imagine you're building a ride-hailing platform where drivers update their location and passengers see it live.

typescript
// Server-side Hub in .NET
public class LocationHub : Hub
{
    public async Task UpdateDriverLocation(string driverId, double latitude, double longitude)
    {
        // Persist to database or cache
        await _locationService.SetDriverLocation(driverId, latitude, longitude);
        
        // Notify all connected clients watching this driver
        await Clients.Group($"driver-{driverId}")
            .SendAsync("LocationUpdated", new { driverId, latitude, longitude });
    }
    
    public async Task NotifyPassenger(string passengerId, string message)
    {
        // Send to a specific client connection
        await Clients.User(passengerId)
            .SendAsync("ReceiveMessage", message);
    }
}

On the client side (React or Next.js), you establish a connection and listen for these method invocations:

typescript
// Client-side React
import * as signalR from "@microsoft/signalr";

const connection = new signalR.HubConnectionBuilder()
    .withUrl("https://api.example.com/locationHub")
    .withAutomaticReconnect()
    .build();

connection.on("LocationUpdated", (data) => {
    setDriverLocation(data);
});

connection.start().catch(err => console.error(err));

// Invoke server method
const updateLocation = async (lat, lon) => {
    await connection.invoke("UpdateDriverLocation", "driver123", lat, lon);
};

The key difference from REST: you don't poll. The server sends data to clients as it happens. This cuts latency from seconds to milliseconds and eliminates wasted requests.

Groups — Target tenants, rooms or users efficiently

Sending data to every connected client isn't practical. Groups let you partition connections logically: by tenant, by feature, by room, or by user role. When you add a connection to a group, only that group receives messages sent to it.

In a multi-tenant SaaS product, you'd never want customer A seeing customer B's data. Groups enforce that boundary at the framework level:

typescript
public class NotificationHub : Hub
{
    private readonly ITenantService _tenantService;
    
    public override async Task OnConnectedAsync()
    {
        var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
        var userId = Context.User?.FindFirst("sub")?.Value;
        
        // Add this connection to tenant-specific group
        await Groups.AddToGroupAsync(Context.ConnectionId, $"tenant-{tenantId}");
        // Also add to user-specific group for 1:1 messages
        await Groups.AddToGroupAsync(Context.ConnectionId, $"user-{userId}");
        
        await base.OnConnectedAsync();
    }
    
    public async Task BroadcastToTenant(string message)
    {
        var tenantId = Context.User?.FindFirst("tenant_id")?.Value;
        await Clients.Group($"tenant-{tenantId}")
            .SendAsync("ReceiveNotification", message);
    }
}

This pattern scales beautifully. A platform with 100,000 concurrent users across 500 tenants can selectively notify only the relevant users without flooding the entire network. Removing a user is equally simple: call Groups.RemoveFromGroupAsync() on disconnect.

Transports — WebSockets first, with SSE and long polling fallbacks

SignalR negotiates the best transport for each client. WebSockets are the gold standard: full-duplex, low-latency, minimal overhead. But not all environments support them (corporate proxies, older browsers). SignalR gracefully falls back to Server-Sent Events (SSE) or long polling.

You don't need to handle this complexity. Configure it once at startup:

typescript
// Program.cs (.NET 6+)
builder.Services.AddSignalR(options =>
{
    options.MaximumReceiveMessageSize = 64 * 1024 * 1024; // 64MB
    options.ServerTimeout = TimeSpan.FromSeconds(30);
    options.KeepAliveInterval = TimeSpan.FromSeconds(15);
})
.AddRedisBackplane("redis-connection-string");

app.MapHub("/locationHub");
app.MapHub("/notificationHub");

The framework handles transport selection during the initial handshake. On the client, you can explicitly prefer a transport or let it auto-detect:

typescript
const connection = new signalR.HubConnectionBuilder()
    .withUrl("https://api.example.com/locationHub", {
        transport: signalR.HttpTransportType.WebSockets |
                   signalR.HttpTransportType.ServerSentEvents |
                   signalR.HttpTransportType.LongPolling
    })
    .withAutomaticReconnect([0, 2000, 5000, 10000]) // Exponential backoff
    .build();

WebSockets will be used if available. SSE is a good middle ground (unidirectional from server to client, works over HTTP/1.1). Long polling is the fallback but creates higher overhead since it relies on repeated requests.

Scale out — Redis backplane or Azure SignalR Service

A single .NET server can handle thousands of concurrent WebSocket connections. But when you scale to multiple app servers, a problem emerges: if Server A has a connected client and Server B needs to send that client a message, how does it reach them?

The answer is a backplane. All servers connect to a shared message broker (Redis, Azure Service Bus, or Azure SignalR Service). When any server sends a message to a group or client, the backplane ensures delivery across the cluster.

Redis backplane is lightweight and self-hosted. Add it with one line:

typescript
builder.Services.AddSignalR()
    .AddRedisBackplane("connection-string-to-redis");

Now, regardless of which load balancer sends a client request to which app server, all servers can coordinate messages through Redis:

Architecture Pros Cons Best for
Single server Simplest, no external deps Can't scale horizontally Prototypes, small products (<1k concurrent)
Redis backplane Self-hosted, low latency, familiar Adds Redis ops burden Mid-scale products (1k–100k concurrent)
Azure SignalR Service Fully managed, auto-scaling, no ops Higher cost, vendor lock-in Enterprise products or rapid scaling

If you're on Azure, SignalR Service is compelling. It handles scale, failover, and diagnostics for you. The cost is higher, but operations burden drops to near-zero. For self-hosted, Redis is battle-tested and runs anywhere Docker runs.

Here's how to configure Azure SignalR Service:

typescript
builder.Services.AddSignalR()
    .AddAzureSignalR(options =>
    {
        options.ConnectionString = "your-signalr-service-connection-string";
        options.ServerStickyMode = ServerStickyMode.Preferred;
    });

The framework handles connection sharding, message routing, and failover transparently.

Common mistakes

  • Not using groups and broadcasting to all clients. This works in demos but becomes expensive fast. Always partition by tenant, feature, or user.
  • Ignoring reconnection logic. WebSocket connections drop. Plan for automatic reconnection with exponential backoff. Your UI should queue messages during disconnects and replay them when reconnected.
  • Forgetting to remove clients from groups. If you don't call Groups.RemoveFromGroupAsync() in OnDisconnectedAsync, memory leaks grow. Always clean up.
  • Treating SignalR as a replacement for Kafka. SignalR is for pushing data to clients. Kafka is for event streaming and async processing. Use both: Kafka processes events, SignalR delivers results to browsers.
  • Running without a backplane in production with multiple servers. This silently fails: some clients miss messages because they're on a server that didn't initiate the send. Always use Redis or Azure SignalR Service in multi-server deployments.

Key takeaways

  • Hubs are bidirectional RPC endpoints: servers and clients call methods on each other without polling.
  • Groups partition connections securely, enabling efficient targeting of tenants, rooms, or users.
  • SignalR negotiates the best transport (WebSockets, SSE, or long polling) automatically; you choose based on environment constraints.
  • Scale out with Redis backplane for self-hosted or Azure SignalR Service for managed convenience.
  • Combine SignalR with Kafka: Kafka processes events asynchronously, SignalR pushes results to connected clients in real time.
  • Always clean up group membership in OnDisconnectedAsync to prevent memory leaks in long-running servers.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles