Horizontal Scaling & Distributed Backplanes: Nginx WebSocket Proxying, Sticky Sessions & Redis Pub/Sub vs Streams
A single Node.js instance cannot hold more than ~65,000 TCP sockets or survive hardware failure. This article scales real-time web applications horizontally: configuring Nginx to correctly proxy WebSocket upgrade headers, demystifying why raw WebSockets do not need sticky sessions while Socket.IO does, and architecting a Redis distributed backplane — evaluating Redis Pub/Sub (AP, at-most-once, fire-and-forget) against Redis Streams (CP, at-least-once, durable consumer groups with MAXLEN memory bounds).
Real-Time Web Architecture: From Polling & SSE to WebSockets & Distributed Backplanes
Horizontal Scaling & Distributed Backplanes: Nginx WebSocket Proxying, Sticky Sessions & Redis Pub/Sub vs Streams
Real-time is not a library you import, but an architectural contract across the network stack: choose the lowest-complexity transport that matches your data symmetry, budget for long-lived socket file descriptors, and decouple stateful client connections from stateless event producers via a distributed backplane. The first four parts built a complete real-time application on a single Node.js instance. That instance is now the single point of failure and the hard ceiling on concurrent connections.
A single Node.js process can handle approximately 65,000 TCP sockets (file descriptor limit) and will saturate its single event-loop thread long before that. Horizontal scaling — running multiple Node.js instances behind a load balancer — introduces an immediate distributed systems problem: Client A connects to Node 1. An event occurs. The server broadcasts to all sockets in the local ConnectionManager. But Client B is connected to Node 2, which has a completely separate ConnectionManager with no knowledge of the event. Client B receives nothing.
This article solves the distributed broadcast problem: configuring Nginx correctly for WebSocket proxy, destroying the sticky session myth, and designing a Redis distributed backplane — with a precise CAP/PACELC analysis of Redis Pub/Sub (AP, at-most-once, fire-and-forget) versus Redis Streams (CP, at-least-once, durable consumer groups with MAXLEN memory bounds).
1. The Horizontal Scaling Problem
1.1 Why Multi-Instance Deployments Break Real-Time
The ConnectionManager is in-process memory. Node 1 cannot reach Node 2's sockets. Without a distributed backplane, every client only receives events from the one node they are physically connected to.
1.2 The Backplane Architecture
The solution is a distributed pub/sub backplane: a shared message bus that all Node.js instances subscribe to. When any node generates a broadcast event, it publishes to the backplane. All nodes (including the publisher) consume from the backplane and deliver to their local sockets:

2. Nginx WebSocket Proxy Configuration
2.1 The Required Headers
By default, Nginx operates as an HTTP/1.0 proxy and strips the Connection and Upgrade headers required for WebSocket protocol negotiation. Without explicit configuration, WebSocket upgrade requests receive a 400 Bad Request or are silently downgraded to plain HTTP:
proxy_http_version 1.1 is mandatory. Nginx defaults to HTTP/1.0 for upstream communication. HTTP/1.0 does not support Connection: upgrade — attempting a WebSocket upgrade over HTTP/1.0 will always fail with a 400 or 426 Upgrade Required error.
2.2 Demystifying the Sticky Sessions Myth
The most repeated misconception in real-time web architecture is that WebSockets require sticky sessions. Let's be precise about what actually needs session affinity:
Raw WebSockets — NO sticky sessions needed:
Once the TCP handshake and WebSocket upgrade complete, the connection is pinned for its lifetime. Nginx's role is purely TCP packet forwarding. No session ID, no cookie, no affinity policy is needed.
Socket.IO — Sticky sessions ARE required:
Socket.IO's Engine.IO transport establishes the session with HTTP polling before upgrading to WebSocket. Multiple HTTP requests must reach the same backend node to find the same in-memory session state. This requires ip_hash or cookie-based sticky sessions in Nginx:
ip_hash breaks when clients are behind NAT or corporate proxies where thousands of users share a single IP. A better option is the @socket.io/sticky Nginx module which uses the Socket.IO sid cookie for affinity. Alternatively, use @socket.io/cluster-adapter with Redis to share session state across nodes.
3. Redis Pub/Sub Backplane
3.1 The Backplane Architecture
3.2 The Hidden Traps of Redis Pub/Sub
At-Most-Once delivery is the fundamental limitation of Redis Pub/Sub:
Redis Pub/Sub drops messages under two conditions:
- A subscriber is disconnected (restarting, network partition, crash) when a message is published.
- A subscriber's output buffer fills faster than it can consume — Redis drops the subscriber.
Client Output Buffer Limits:
During a message spike (broadcast to 50,000 clients, each on a different tenant → 50,000 Redis publishes in 1 second), a slow subscriber node will exceed the soft limit and get disconnected by Redis, losing all pending messages.
4. Redis Streams: Durable At-Least-Once Backplane
4.1 Redis Streams vs Pub/Sub
Redis Streams (XADD, XREADGROUP, XACK) provide a durable, replayable event log — the persistent counterpart to Pub/Sub's fire-and-forget model:

4.2 CAP / PACELC Analysis
| Property | Redis Pub/Sub | Redis Streams |
|---|---|---|
| CAP Alignment | AP | CP |
| PACELC | PA/EL (prefer availability + low latency) | PC/EC (prefer consistency + durability) |
| Delivery Guarantee | At-Most-Once (drop on disconnect) | At-Least-Once (redelivery until XACK) |
| Durability | None — message is gone after fanout | Persistent until MAXLEN trim or explicit DELETE |
| Replay | Impossible | Yes — via XREADGROUP with 0 start or XAUTOCLAIM |
| Memory | ~O(1) — no persistence | O(stream_length) — must cap with MAXLEN |
| Latency | Sub-millisecond | 1–5ms (persistence write) |
| Best For | Ephemeral real-time telemetry (GPS, cursor positions) | Financial events, chat messages, order status changes |
4.3 The MAXLEN Imperative
Redis Streams are persistent log structures. Without a size cap, they grow indefinitely:
The ~ (approximate) modifier allows Redis to trim in bulk when the stream exceeds threshold rather than on every write — significantly reducing CPU overhead.
5. Broadcast Amplification: The O(M × N) Problem
When a single event must be delivered to N clients across M nodes, the Redis publish triggers M × N socket writes:
| Nodes | Clients/Node | Event recipients | Socket writes | Redis publishes |
|---|---|---|---|---|
| 3 | 1,000 | 3,000 | 3,000 | 1 |
| 10 | 5,000 | 50,000 | 50,000 | 1 |
| 20 | 5,000 | 100,000 | 100,000 | 1 |
The Redis publish is O(1). The local socket writes are O(N/M) per node. The network card saturation happens on the write path, not the pub/sub path.
Topic sharding mitigates amplification:
Summary
| Concept | Rule |
|---|---|
| Stateful WebSocket connections terminate on a specific physical server; horizontal scaling requires a distributed Redis backplane to route broadcast events across all cluster nodes. | Publish to tenant:<id> Redis channel from any node; all nodes subscribe and deliver locally to their ConnectionManager sockets. |
Nginx requires explicit Upgrade and Connection header proxying with proxy_http_version 1.1; omitting these causes the proxy to reject WebSocket upgrades with a 400 error. |
Always set proxy_set_header Upgrade $http_upgrade, proxy_set_header Connection "upgrade", and proxy_http_version 1.1 for WebSocket location blocks. |
| Raw WebSockets do not require sticky sessions — the TCP connection pins to one node post-handshake; sticky sessions are only necessary for Socket.IO's multi-request HTTP polling handshakes. | Use ip_hash or @socket.io/sticky only for Socket.IO deployments; raw ws works correctly with plain round-robin load balancing. |
| Redis Pub/Sub is AP: messages are dropped silently when a subscriber disconnects during a partition; Redis Streams is CP: durable consumer group offsets enable deterministic at-least-once recovery. | Use Redis Pub/Sub for ephemeral telemetry (GPS, cursors); use Redis Streams with XACK for financial events, chat, and order status where message loss is unacceptable. |
Redis Streams must be capped with XADD MAXLEN to enforce strict memory bounds; uncapped streams on high-velocity event buses will exhaust server RAM and trigger OOM crashes. |
Set MAXLEN ~ 10000 per tenant stream; monitor stream length with XLEN events:<tenantId> in your observability pipeline. |
What's Next
The distributed backplane solves the multi-node fanout problem. But operating at scale introduces a new attack surface and failure modes: Cross-Site WebSocket Hijacking (CSWSH) bypasses CORS entirely, rolling deployments sever thousands of connections simultaneously, and slow clients silently exhaust server heap memory via socket backpressure accumulation. Part 6 hardens the complete system: Origin header whitelisting, cryptographic token defenses, exponential backoff with full jitter for connection storm mitigation, lossy conflation vs lossless flow-control backpressure policies, and a final comparison with the WebTransport over HTTP/3 horizon.
Part 6 → Production Hardening: CSWSH Security, Connection Storms, Socket Backpressure & WebTransport Horizons —
realtime-production-hardening-backpressure-webtransport(coming in Part 6 of this series)
This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.