The challenge of two servers with one connection pool
When your SignalR hub runs on a single server, every client connection lives in memory on that same machine. Messages broadcast instantly because sender and receiver share the same process. The moment you deploy two instances behind a load balancer, that simplicity evaporates. A client connected to Server A has no knowledge of clients connected to Server B, and vice versa. Scaling SignalR across servers: best practices explained requires understanding how messages flow between isolated connection pools and which infrastructure patterns actually work at scale.
We've seen teams launch with multiple SignalR instances only to discover that 50% of their broadcast messages never reach connected clients. The root cause is almost always architectural—not a bug in the code. Let's walk through the patterns that keep real-time communication reliable under load.
Why direct server-to-server messaging fails
Before tackling solutions, let's be clear about the problem. Each SignalR server maintains its own in-memory dictionary of connected clients and their connection IDs. When you call Clients.All.SendAsync() on Server A, that message reaches only the clients directly connected to Server A. Clients on Server B never receive it.
This is by design—SignalR doesn't attempt to route messages between servers automatically. It has no built-in knowledge of other server instances, their clients, or network topology. You must provide that layer yourself, and that's where a backplane comes in.
Redis backplane broadcasts messages to every server
A backplane is a publish-subscribe system that sits between your SignalR instances. When any server broadcasts a message, it first publishes to the backplane. Every server subscribes to the backplane and receives that message, then delivers it to their local clients. This ensures all connected clients see the message regardless of which server they're attached to.
Redis is the most common backplane choice for on-premises and cloud deployments. It's fast, reliable, and proven under high throughput. Here's how you wire it up in ASP.NET Core:
services.AddSignalR()
.AddRedisBackplane(options =>
{
options.Configuration = ConnectionMultiplexer.Connect("redis-server:6379");
});
app.MapHub<ChatHub>("/chathub");With this configuration, when a client sends a message through one server, the hub method publishes to Redis. Every server instance—including the one that received the initial client message—subscribes to that Redis channel. The message is delivered to all connected clients across all servers.
Let's look at a concrete hub example:
public class ChatHub : Hub
{
public async Task BroadcastMessage(string user, string message)
{
// This publishes to Redis, which distributes to all servers
await Clients.All.SendAsync("ReceiveMessage", user, message);
}
public async Task NotifyServerGroup(string groupName, string content)
{
// Groups also go through the backplane
await Clients.Group(groupName).SendAsync("GroupUpdate", content);
}
}When Client X on Server A calls BroadcastMessage, Server A publishes to Redis. Server A itself re-subscribes and delivers to its local clients. Server B also receives it and delivers to its connected clients. The latency added is typically under 5ms in a well-configured Redis setup, which is transparent to users.
Groups and users work the same way. Call Groups.AddToGroupAsync() and the group membership is tracked locally on each server. When you send to a group, the backplane ensures every server checks its local membership list and delivers accordingly.
Sticky sessions required unless using WebSockets only
Here's where many teams get caught: if your clients use long-polling or server-sent events (SSE), you need sticky sessions. Sticky sessions ensure that each client always routes to the same server instance. Without them, a client's next HTTP request might land on Server B while their SignalR connection context lives on Server A—and messages won't flow correctly.
WebSockets, however, are different. A WebSocket connection is stateful and persistent. Once established with Server A, subsequent frames arrive on the same TCP connection. The load balancer has nothing to do—the kernel routes them directly. This is why many teams prefer WebSockets for SignalR in production: they eliminate the sticky-session requirement.
If you must support long-polling (for legacy clients or corporate networks that block WebSocket), enable sticky sessions on your load balancer:
| Load Balancer | Configuration Method |
|---|---|
| AWS ALB | Enable stickiness via target group attributes; set duration to 1 day or match your session timeout |
| Azure Load Balancer | Session persistence not directly available; use Application Gateway with cookie-based affinity |
| NGINX | ip_hash; in upstream block, or use sticky cookie for application-level affinity |
| HAProxy | cookie SERVERID insert indirect nocache on backend servers |
That said, we recommend defaulting to WebSockets. They're more efficient, lower latency, and eliminate this entire category of operational complexity. Configure your hub to prefer WebSockets:
app.MapHub<ChatHub>("/chathub", options =>
{
options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
});
// Or more strictly, WebSocket only:
options.Transports = HttpTransportType.WebSockets;If you go WebSocket-only, you can typically remove sticky-session logic from your load balancer. The connection is pinned at the TCP level, not the HTTP request level.
Azure SignalR Service offloads connections entirely
For teams running on Azure or seeking a managed solution, Azure SignalR Service is a purpose-built platform that handles scaling for you. Instead of running SignalR hubs in your app servers, you connect to a managed Azure service. The service maintains all client connections, manages backplane logic, and scales horizontally behind the scenes.
Your ASP.NET Core app becomes a stateless connection handler. You send messages to the service via REST API or SDK, and the service delivers them to connected clients. This eliminates local connection state, sticky sessions, and backplane operational overhead.
Integration is straightforward:
services.AddSignalR()
.AddAzureSignalR(options =>
{
options.ConnectionString = configuration.GetConnectionString("AzureSignalR");
options.ServerStickyMode = ServerStickyMode.Required; // or Preferred/Disabled
});
app.MapHub<ChatHub>("/chathub");The ServerStickyMode setting controls how the service routes clients. Required means clients always route to the same app server instance. Preferred is best-effort. Disabled allows any instance to handle any client, which works well for truly stateless hubs.
Advantages of Azure SignalR Service include automatic scaling based on connection count, built-in high availability, and integration with Azure Monitor. The tradeoff is cost and vendor lock-in. For greenfield projects on Azure where cost is not the primary constraint, it's worth evaluating.
Load test connection counts before launch day
Theory and practice diverge when you're under load. A single SignalR connection consumes memory—for the connection object, message queue, and authentication state. On a 4 GB app server, you might sustain 50,000 concurrent connections in one environment but only 30,000 in another, depending on message size, frequency, and backplane latency.
Before launching, spin up your exact target infrastructure (two or more instances behind your load balancer with your chosen backplane) and load test against it. Use SignalR's built-in load testing tools or open-source projects like signalr-bench.
A realistic test looks like this:
- Establish 10,000 concurrent connections across both servers (5,000 each)
- Each connection broadcasts a message every 5 seconds
- Measure message latency (time from send to receive)
- Monitor CPU, memory, and Redis throughput on the backplane
- Gradually increase to 50,000 and observe behavior
You're looking for latency staying under 100ms, CPU not exceeding 70%, and Redis pub-sub not becoming a bottleneck. If Redis CPU spikes to 95% at 30,000 connections, you've found your limit and need to scale Redis (clustering, replication) or reduce message frequency.
Document these numbers. They become your operational safety threshold. If production connection count approaches this limit, you know it's time to add servers or scale the backplane.
Common mistakes
Assuming the backplane is optional
Teams sometimes deploy two instances without a backplane, expecting it to "just work." SignalR won't magically coordinate across servers. You'll see users complaining that they received some messages but not others. Always deploy a backplane in production—Redis, RabbitMQ, or Azure SignalR Service.
Mixing transports without sticky sessions
Enabling both WebSocket and long-polling without sticky sessions causes intermittent failures. A client connects via WebSocket to Server A, then falls back to long-polling during network hiccup. The long-polling request routes to Server B, and the client has no connection context there. Disable long-polling entirely if possible, or enforce sticky sessions rigorously.
Testing with a single server, deploying with two
Single-server tests pass because all clients are local. Multi-server behavior is unpredictable without testing against the actual topology. Always test locally with docker-compose spinning up two app instances and Redis, even if you'll use Azure SignalR in production.
Ignoring backplane latency in group operations
When you add a client to a group and immediately broadcast to that group, the group membership update must propagate through the backplane first. In high-throughput scenarios, this can introduce race conditions. Consider whether you need to add a small delay or use transactions to ensure consistency.
Not monitoring Redis connection pool exhaustion
Each app instance consumes Redis connections for the SignalR backplane. If you have 10 app instances and each opens 5 connections, that's 50 connections to Redis. On a constrained Redis setup, the connection pool exhausts and new instances fail to join. Monitor Redis connected_clients and ensure your Redis instance has sufficient capacity.
Key takeaways
- Multiple SignalR servers require a backplane (Redis, RabbitMQ, or managed service) to coordinate messages across isolated connection pools.
- Use WebSockets as your primary transport to avoid sticky-session complexity; only fall back to long-polling when necessary and ensure your load balancer enforces affinity.
- Redis is the most common backplane for on-premises and hybrid cloud; it adds sub-5ms latency and scales to hundreds of thousands of concurrent connections with proper tuning.
- Azure SignalR Service offloads all connection management to a managed platform, eliminating local state and scaling concerns at the cost of vendor lock-in and additional expense.
- Load test your target architecture with realistic connection counts, message patterns, and backplane configuration before launch to identify bottlenecks and set operational thresholds.
- Monitor backplane performance (pub-sub latency, Redis CPU, connection pool utilization) in production to catch scaling limits before they affect users.






Comments
Loading comments…