Scaling WebSockets is harder than scaling a normal HTTP API because each WebSocket connection is pinned to the server that accepted it. An HTTP request can land on any replica and finish there. A WebSocket stays open for minutes or hours on one process, and only that process can write to it. Once you run more than one replica, a message that arrives on one server often has to reach a user connected to another.
The standard fix is a pub/sub layer between your servers: every server publishes incoming messages to a broker, subscribes to it, and delivers only to the connections it holds. Below is that design, a comparison of Redis Pub/Sub, RabbitMQ and Kafka for the job, and the production details: sticky sessions, reconnect storms and presence.
Why scaling WebSockets breaks with more than one server
Take a chat service with three pods. User A is connected to pod 1 and user B to pod 3. When A sends a message to B, it arrives at pod 1. Pod 1 has no socket for B, because its in-memory map of connections only knows about users connected to pod 1.
With one server this never shows up, so it usually appears after the first scale-out: messages reach some users and not others, depending on which pod each one hit. Turning on sticky sessions changes nothing, because A and B are both correctly connected. The pods just have no way to talk to each other.
The fan-out pattern: a broker between pods
Put a message broker behind the pods and change what "send" means:
- A sends a message over the WebSocket to pod 1.
- Pod 1 publishes it to the broker instead of looking for B locally.
- The broker delivers it to every subscribed pod.
- Each pod checks whether the recipient has a connection on it. Pod 3 does, so it writes the message to B's socket. Pods 1 and 2 drop it.
Apart from the sockets they hold, the pods stay stateless, so you can add or remove replicas without coordinating them.
A minimal Go hub with Redis Pub/Sub
This hub uses gorilla/websocket and go-redis v9. Each pod keeps a map of its local connections and runs one Redis subscription.
package realtime
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
)
const channel = "chat:messages"
type Message struct {
To string `json:"to"`
From string `json:"from"`
Body string `json:"body"`
}
type client struct {
userID string
send chan []byte
}
type Hub struct {
rdb *redis.Client
mu sync.RWMutex
clients map[string]map[*client]struct{} // userID -> connections on this pod
}
func NewHub(rdb *redis.Client) *Hub {
return &Hub{rdb: rdb, clients: make(map[string]map[*client]struct{})}
}
// Run holds one subscription per pod and delivers to local connections only.
func (h *Hub) Run(ctx context.Context) {
sub := h.rdb.Subscribe(ctx, channel)
defer sub.Close()
ch := sub.Channel()
for {
select {
case <-ctx.Done():
return
case msg, ok := <-ch:
if !ok {
return
}
var m Message
if err := json.Unmarshal([]byte(msg.Payload), &m); err != nil {
continue
}
h.deliverLocal(m.To, []byte(msg.Payload))
}
}
}
func (h *Hub) deliverLocal(userID string, payload []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for c := range h.clients[userID] {
select {
case c.send <- payload:
default: // client is too slow; never block the whole pod
}
}
}
func (h *Hub) add(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
if h.clients[c.userID] == nil {
h.clients[c.userID] = make(map[*client]struct{})
}
h.clients[c.userID][c] = struct{}{}
}
func (h *Hub) remove(c *client) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.clients[c.userID], c)
if len(h.clients[c.userID]) == 0 {
delete(h.clients, c.userID)
}
close(c.send)
}
// The default CheckOrigin rejects cross-origin upgrades.
var upgrader = websocket.Upgrader{}
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request) {
userID := r.Header.Get("X-User-ID") // set by your auth middleware
if userID == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
c := &client{userID: userID, send: make(chan []byte, 64)}
h.add(c)
defer h.remove(c)
// gorilla/websocket allows one concurrent writer per connection.
go func() {
for payload := range c.send {
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
}
}()
for {
_, data, err := conn.ReadMessage()
if err != nil {
return
}
var m Message
if err := json.Unmarshal(data, &m); err != nil {
continue
}
m.From = userID // never trust a sender field from the client
out, err := json.Marshal(m)
if err != nil {
continue
}
if err := h.rdb.Publish(r.Context(), channel, out).Err(); err != nil {
log.Printf("publish: %v", err)
}
}
}Two points matter here. First, never block the subscriber loop: one slow client in deliverLocal would stall delivery for everyone on that pod, so the buffered channel with a default case drops messages instead (in production you would usually disconnect that client). Second, persist before you publish. Chat history belongs in a database, and pub/sub handles live delivery only.
Channel design: broadcast everything or subscribe by interest
The example sends every message to every pod. That's fine for a handful of pods, but most pods discard most of what they receive, and the waste grows with pods multiplied by messages. The next step is interest-based channels: publish to room:<id> or user:<id>, and have each pod subscribe only to the channels of rooms or users it currently holds. go-redis lets you call Subscribe and Unsubscribe on an existing PubSub as connections come and go. You trade some bookkeeping for much less wasted traffic.
Redis Pub/Sub vs RabbitMQ vs Kafka for realtime fan-out
All three work. They differ in delivery guarantees and operating cost.
| Redis Pub/Sub | RabbitMQ | Kafka | |
|---|---|---|---|
| Delivery | At-most-once, fire-and-forget | Acknowledged, optionally durable | Stored in a log, replayable |
| Messages published while a pod is disconnected | Lost | Kept if the queue survives | Kept until retention expires |
| Latency | Lowest | Low | Higher, tuned for throughput |
| Fan-out setup | Every subscriber of a channel gets every message | Fanout or topic exchange, one queue per pod | One consumer group per pod |
| Operational weight | Light | Medium | Heaviest |
| Good fit | Chat, notifications, live dashboards | Messages that must not be lost, complex routing | Very high volume, same events feeding many systems |
Redis Pub/Sub is the usual default, and Socket.IO's Redis adapter is built on it. Redis doesn't store the message, so a pod that loses its Redis connection for a moment misses whatever was published during that gap. For live chat that's usually acceptable, because clients reload recent history from the database when they reconnect. If you need a buffer, Redis Streams add persistence and consumer groups on the same server. On Redis Cluster, a classic PUBLISH is broadcast to every node. Redis 7 added sharded pub/sub (SPUBLISH and SSUBSCRIBE), which keeps each channel on its own shard. More in Redis caching, data structures and persistence.
RabbitMQ fits when a realtime message must not be lost, such as a payment confirmation. Each pod declares its own exclusive, auto-delete queue bound to a fanout or topic exchange, and a topic exchange can route by room or tenant. See RabbitMQ exchanges and reliable messaging for the details.
Kafka makes sense when the same events also feed analytics, audit and search. It has one trap for fan-out. Consumers in the same group split the partitions between them, so if all your WebSocket pods share one group ID, each pod sees only part of the stream. For broadcast, give each pod its own group and start from the latest offset. Apache Kafka partitions and consumer groups explains why.
Sticky sessions: when you need them and when you don't
A native WebSocket doesn't need sticky sessions: after the upgrade, the connection stays on one pod until it closes.
You do need them when:
- The client can fall back to HTTP long-polling. Socket.IO and SockJS may send a series of separate HTTP requests that belong to one session. If those requests hit different pods, the handshake fails. Either enable affinity (by cookie or client IP) at the load balancer, or configure clients to use the WebSocket transport only.
- You keep resumable session state in pod memory. Stickiness helps, but moving that state into Redis is usually the better fix.
Also check idle timeouts. Many proxies and cloud load balancers close idle connections after about a minute, so raise them (in ingress-nginx, proxy-read-timeout and proxy-send-timeout) or ping more often than the shortest timeout on the path.
Rollouts, scale-out and reconnect storms
Long-lived connections change how deployments and autoscaling behave:
- New pods don't take over existing connections. Scale from 3 to 6 replicas and existing sockets stay put. Only new connections spread out.
- Every pod shutdown drops its sockets. A rolling update can make thousands of clients reconnect at once. Clients should retry with exponential backoff and jitter, and servers should handle
SIGTERMby sending close frames and draining gradually. - Scale on connections, not only CPU. An idle socket costs memory and a file descriptor but almost no CPU.
Tracking presence across pods
"Is B online?" has the same problem as delivery: the answer is spread across pods. Keep presence in Redis, not in pod memory.
A sorted set per user works well. Each connection adds its ID with the current Unix timestamp as the score and refreshes it on every heartbeat. A user is online if any entry is recent.
# on connect and on every heartbeat (for example every 30s)
ZADD presence:user:42 1790000000 conn-7f3a
# online if any connection checked in within the last 60s
ZCOUNT presence:user:42 1789999940 +inf
# on clean disconnect
ZREM presence:user:42 conn-7f3a
# periodic cleanup of entries left by crashed pods
ZREMRANGEBYSCORE presence:user:42 -inf 1789999940Heartbeat expiry matters because a crashed pod never runs its disconnect handlers. Counting connections instead of storing one flag also handles users with several tabs open.
Publish presence changes only to the users who care, such as friends or room members, and debounce them so a flaky mobile connection doesn't produce a stream of online and offline events.
FAQ
How many WebSocket connections can one server handle?
It depends on your workload, so load-test. The first limits are usually file descriptors, buffer memory and the proxy in front, not CPU.
Do I need sticky sessions for WebSockets?
Not for native WebSocket connections, which stay on one server anyway. You need them if your library can fall back to HTTP long-polling, as Socket.IO can.
Is Redis Pub/Sub reliable enough for chat?
For live delivery, usually yes, if messages are stored in a database first and clients reload recent history on reconnect. If a lost realtime message is unacceptable, use Redis Streams, RabbitMQ or Kafka.
Can I use Kafka instead of Redis for scaling WebSockets?
Yes, but give each WebSocket pod its own consumer group so every pod sees every message. For pure fan-out it's usually more than you need.
Checklist for scaling WebSockets
- Every pod publishes incoming messages to a broker and delivers only to its own connections.
- Messages are persisted before they are published.
- Slow clients can't block the subscriber loop.
- Proxy idle timeouts are longer than the ping interval.
- Clients reconnect with backoff and jitter, and pods drain on shutdown.
- Presence lives in Redis with heartbeat expiry, not in pod memory.
- Sticky sessions are enabled only where a long-polling fallback is in use.
Start with Redis Pub/Sub and one channel, measure, and move to interest-based channels or a durable broker only when the numbers or delivery requirements demand it. If you're planning a realtime feature and want a second opinion on the design, Vectorkub builds and reviews systems like this.
