Skip to content

BlogEngineering Resources

Scaling WebSockets: Fanout Across Servers

Scaling WebSockets: Fanout Across Servers

In the usual independent-worker deployment model, a WebSocket or SSE connection spans kernel-managed socket state and application metadata and buffers owned by one process. A machine may run four workers, and a socket accepted by worker 2 is ordinarily unavailable to worker 3. Inherited descriptors, descriptor transfer, or explicit handoff can make it available elsewhere, but those mechanisms still require coordination. Publication may begin in a request handler, background consumer, outbox publisher, or relay. That process still needs a path to the process holding the recipient's connection.

That process boundary creates separate provisioning and coordination problems. Connection capacity depends on file descriptor limits, memory per connection, message rate, TLS work, and load-tested latency. For delivery, user 4,821 sends to a 40-member room spread across three processes, while the process receiving the publication holds nine connections. The design must reach the other thirty-one.

A message broker can move events between processes, but targeted delivery also needs some way to resolve rooms or recipients to the processes holding their current sessions. That resolution can come from broker subscriptions, an external presence registry, sharded channels, or an ownership layer. The appropriate choice depends on connection cardinality, room overlap, subscription churn, and the cost of sending irrelevant events.

Stage one: one server, and the honesty to say so

With one server process, fanout is a loop over an in-memory map from room ID to local connections, followed by a write to each socket. No broker, cross-process coordination, or distributed presence state is required.

This can be a legitimate end state. Capacity depends on message rate, payload size, TLS termination, buffer policy, and work per message, so the useful number comes from a load test that measures memory per connection and tail latency under the expected traffic pattern. If that test shows one process can serve a dashboard's 8,000 concurrent viewers with suitable headroom, adding a Kafka cluster does not improve the design. Reasons to leave the single-process model include exceeding its tested capacity, supporting deployments without disconnecting every client, or reducing the connection loss caused by one process failure.

The loop stops being sufficient when a second process accepts connections. A load balancer chooses where a connection lands, but it does not route later application events. User 4,821's connection may be on instance B while the publish for their room executes on instance A. Instance A's local room map has no entry for that session, so a local-only fanout reaches only members connected to A.

Stage two: colocate the room

One possible fix is to route all connections for a room to the same process. Hash the room ID to a server, have a connection gateway honor it, and fanout locally. This fits a room-dedicated stream with one placement key. A chat connection may join overlapping rooms; those memberships must resolve to a common placement domain, such as a tenant, workspace, or connected overlap component, that fits one process. If independently assigned rooms cannot satisfy that constraint, the system needs an ownership layer or cross-process fanout.

Even where room colocation fits, it carries two costs.

Hot rooms. Placement by room makes the room the unit of capacity. A room with 200,000 connections must fit on its assigned server unless the ownership scheme can subdivide it. Adding unrelated servers does not relieve that server.

Blast radius. When a server dies, every room assigned to it loses all of its connections. Those clients may reconnect together and overload the process that inherits the hash range. Spreading a room across the fleet instead limits one server failure to a fraction of the room, though it requires cross-process fanout.

flowchart TB
    subgraph S1["Stage 1: one process"]
        A["All connections in one process"] --> B["Local map: room to connections"]
    end
    subgraph S2["Multi-process option: constrained colocation"]
        C["Room-dedicated or shared-domain connection"] --> D["Hash placement domain to one process"]
        D -.-> E["Limits: incompatible memberships, hot domains, failure concentration"]
    end
    subgraph S3["Multi-process option: cross-process routing"]
        P["Publish on any instance"] --> R{"Routing mode"}
        R --> ALL["Broadcast channel"]
        ALL --> F["All processes filter against local maps"]
        R --> SUB["Broker subscription state resolves room or user"]
        R --> REG["External registry resolves recipient to session/process set"]
        R --> OWN["Partition or gateway owner resolves targets"]
        SUB --> T["Target process channels"]
        REG --> T
        OWN --> T
        T --> L["Target processes deliver to local connections"]
        REG -.->|"optional failure fallback"| ALL
    end
    B --> M{"Another process<br/>accepts connections"}
    M -->|"placement domain fits"| S2
    M -->|"broadcast or targeted fanout"| S3
    S2 -.->|"placement constraint fails"| S3

Stage three: routing across processes

The simplest cross-process design is broadcast-and-filter. Every server subscribes to a shared channel, every publish reaches every server, and each server checks its local connection map. At three instances, this may be entirely adequate. As the fleet or message rate grows, each process spends more bandwidth and CPU deserializing events for rooms it does not serve.

Dynamic broker subscriptions avoid much of that waste without a separate registry. A process subscribes to a room channel when its first local connection joins that room and unsubscribes when the last leaves. For direct messages, several processes can subscribe to the same user channel, covering multiple devices and browser sessions. The broker's subscription state then performs the routing. The trade-offs are channel cardinality, join and leave churn, and races between local and broker state. Subscription confirmation or another linearization protocol can control those races. Durable catch-up is needed only when promised semantics require recovery; lossy or replaceable streams may accept omissions.

Sharded channels reduce subscription cardinality by hashing many rooms or users onto each channel. They sit between full broadcast and exact targeting: fewer channels and less control-plane churn in exchange for some irrelevant events at each subscriber. This is useful when exact per-room subscriptions cost more than filtering a bounded shard.

An external presence registry is warranted when publishers need explicit target processes, when broker subscription state is not queryable in the required way, or when routing decisions combine presence with other constraints. Presence is multi-valued: a user may have several sessions, devices, tabs, and connections on different processes. A registry therefore maps a room or recipient to a set of session and process records, not one user to one server. Durable room membership belongs in the system of record; presence only describes currently reachable sessions.

Those records normally use leases or heartbeats because a crashed process cannot clean them up. Session identifiers and connection epochs let cleanup use compare-and-delete semantics, preventing a delayed close from deleting a newer reconnect. During migration from instance B to C, both records may briefly exist, producing duplicate routing, or neither may be visible, producing a missed live notification. Delivery must tolerate stale targets; durable replay repairs omissions only when promised semantics require recovery. If the registry fails, a system may fall back to broadcast-and-filter, continue using cached routes with bounded staleness, or reject live publication. The choice depends on whether extra load, delayed delivery, or temporary unavailability is safer.

Partition or gateway ownership is another option. A stable owner for a room or user can resolve recipients and coordinate ordering while separate connection processes hold the sockets. This centralizes routing decisions but introduces partition hotspots, ownership migration, and a recovery path when an owner fails. It does not remove presence state; it changes which component maintains and acts on it.

Choosing the fanout transport

Kafka can remain useful as a durable message log or as input to downstream processing, but creating a topic or consumer group for every user is usually awkward when routing cardinality and session churn are high. A separate pub/sub layer can handle last-hop delivery while the durable log or database retains messages. This is a composition of responsibilities, not a general verdict against Kafka.

Redis Pub/Sub is at-most-once and non-durable. The Redis documentation states that a subscriber disconnected when a message is published does not receive it. Pub/Sub alone therefore cannot answer what user 4,821 missed while offline. Replay requires retained storage such as a message database, Redis Streams, NATS JetStream, or RabbitMQ Streams. A durable per-recipient RabbitMQ queue can redeliver pending or unacknowledged messages, but it does not retain acknowledged history for cursor replay.

The reconnect story

Long-lived connections can drop when networks change, devices sleep, load balancers time out, or servers restart. SSE provides cursor transport for recovery: a server can attach an id: to an event, and the browser sends Last-Event-ID when it reconnects. This does not provide replay by itself. The ID must identify a position in retained storage, and any server accepting the reconnect must be able to read from that position. WebSockets need an application-level equivalent, typically a client-held cursor sent with a catch-up request.

One sufficient cursor-based recovery design crosses these boundaries:

  • Commit the message and its cursor before live publication. A transactional outbox can atomically store a local write and publication intent; its relay needs retries and visible handling for terminal failures.
  • The client detects gaps in an apparently healthy session. Later sequence numbers expose missing events; high-water marks or acknowledgements reveal loss when no later event arrives.
  • Catch-up and live delivery need a handoff. A server can subscribe, record a durable high-water mark, replay through it while buffering newer events, then drain the buffer with deduplication.
  • Retries create duplicates, so stable IDs support idempotent application or client-side deduplication. Retention sets a recovery limit; an older cursor requires a snapshot or explicit resynchronization.

Cursor replay is not the only at-least-once design. In an acknowledgement-driven protocol, the sender retains durable per-recipient state and retransmits each unacknowledged message. Missing acknowledgements are detected server-side rather than through client gap detection.

At the chosen delivery boundary, every required message must remain recoverable until acknowledged or terminally resolved, and duplicates must be handled where retries can expose them. Durable storage or reconnect replay alone does not prove that invariant; publication coordination, retry or replay, retention, and terminal-failure handling must support it.

Backpressure, stickiness, and delivery guarantees

Slow consumers. A client on a constrained network can drain its socket more slowly than a room produces messages. Each connection needs a bounded queue, usually limited by both bytes and message count, so one session cannot consume the process's memory. Overflow policy changes semantics. Disconnecting the client and requiring cursor-based recovery preserves recoverability within retention but creates interruption and reconnect load. Coalescing is suitable for replaceable state updates, such as the latest dashboard value, but not for ordered chat messages. Dropping low-priority events is valid only when the product can expose that loss. Durable storage should hold recoverable history; an unbounded in-memory socket queue is not a substitute.

Isolation matters beyond the queue limit. A slow socket should not block writes to other connections, and broker consumption should not be acknowledged as complete merely because an event entered volatile process memory if the promised boundary requires later recovery. Per-connection scheduling, bounded write batches, and process-level admission controls keep slow sessions from exhausting shared event-loop time or memory.

Load balancer behavior. Long-lived connections balance at establishment time, so a newly deployed instance may remain lightly loaded while older instances retain established sessions. Connection counts can skew after partial restarts, and idle timeouts can sever quiet connections unless the protocol sends suitable heartbeats. Sticky placement can improve locality, but it does not replace cross-process routing for overlapping rooms or multi-device users.

Each hop should state its own semantics. Redis Pub/Sub is at-most-once. A durable relay may retry publication. A connection write does not prove that the client applied the message, and a client acknowledgement does not repair a lost durable commit. The design is complete only when those boundaries, their failure behavior, and the recovery path agree.

Disclosure: Formation publishes this blog and provides technical interview preparation for experienced engineers. Formation can help you practice explaining these routing and delivery trade-offs under review, but the architecture choices still depend on the workload and guarantees of the system being designed.

Share this post