Siddhant Deval
Siddhant Deval
backend5 min read

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).

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:

TEXT
Node 1 generates event → publish to Redis channel "tenant:T1"
Node 1 (as subscriber) → receives from Redis → delivers to local T1 sockets
Node 2 (as subscriber) → receives from Redis → delivers to local T1 sockets
Node 3 (as subscriber) → receives from Redis → delivers to local T1 sockets
Multi-node cluster with Redis Pub/Sub: broadcasts from any Node instance fan out through Redis to reach all connected client sockets
Multi-node cluster with Redis Pub/Sub: broadcasts from any Node instance fan out through Redis to reach all connected client 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:

NGINX
# /etc/nginx/sites-available/fleettracker.conf

upstream fleet_ws {
    # Round-robin load balancing across Node.js instances
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
    keepalive 256; # Persistent upstream connections
}

upstream fleet_api {
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
    keepalive 64;
}

server {
    listen 443 ssl http2;
    server_name api.fleettracker.io;

    # WebSocket endpoint
    location /ws/ {
        proxy_pass http://fleet_ws;

        # Critical: Must use HTTP/1.1 — WebSocket upgrades require HTTP/1.1
        proxy_http_version 1.1;

        # Critical: Pass Upgrade and Connection headers to upstream
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        # Standard proxy headers
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # Extend timeout for persistent WebSocket connections
        # Default is 60s — WebSocket connections are long-lived
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;

        # Disable response buffering for WebSocket frames
        proxy_buffering off;
    }

    # REST API endpoint (standard HTTP)
    location /api/ {
        proxy_pass http://fleet_api;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
    }
}
Crucial Requirement

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:

TEXT
1. Client opens WebSocket to Nginx
2. Nginx routes the HTTP/1.1 Upgrade request to Node 2 (round-robin)
3. Upgrade completes — TCP connection is now pinned to Node 2 at the transport layer
4. All subsequent frames from this client flow through the same TCP connection
5. Nginx forwards TCP packets from this established connection to Node 2
6. No additional HTTP requests are made — there is no session to correlate

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:

TEXT
1. Client sends: POST /socket.io/?EIO=4&transport=polling  → Nginx routes to Node 2
2. Node 2 assigns session ID: sid=ABC
3. Client sends: GET /socket.io/?EIO=4&transport=polling&sid=ABC
   → Nginx round-robins to Node 1
   → Node 1 has NO record of sid=ABC
   → 400 Session ID Unknown ← FAILURE

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:

NGINX
upstream fleet_socketio {
    # ip_hash: routes clients to the same backend based on client IP
    ip_hash;
    server 127.0.0.1:3001;
    server 127.0.0.1:3002;
    server 127.0.0.1:3003;
}
Performance / Safety Warning

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

TYPESCRIPT
// server/lib/RedisPubSubBackplane.ts
import { createClient, type RedisClientType } from 'redis'
import { connectionManager } from './ConnectionManager'

class RedisPubSubBackplane {
  private publisher!: RedisClientType
  private subscriber!: RedisClientType

  async connect(url: string): Promise<void> {
    // Redis requires separate clients for pub and sub
    this.publisher = createClient({ url })
    this.subscriber = createClient({ url })

    await Promise.all([this.publisher.connect(), this.subscriber.connect()])
  }

  /** Publish a tenant event to the Redis channel (called by ChangeStreamWorker) */
  async publish(tenantId: string, event: unknown): Promise<void> {
    await this.publisher.publish(
      `tenant:${tenantId}`,
      JSON.stringify(event),
    )
  }

  /** Subscribe this node to all tenant channels it has active connections for */
  async subscribeTenant(tenantId: string): Promise<void> {
    await this.subscriber.subscribe(`tenant:${tenantId}`, (message) => {
      const event = JSON.parse(message)
      // Deliver to all local sockets for this tenant
      connectionManager.broadcastToTenant(tenantId, event)
    })
  }

  async unsubscribeTenant(tenantId: string): Promise<void> {
    await this.subscriber.unsubscribe(`tenant:${tenantId}`)
  }
}

export const backplane = new RedisPubSubBackplane()
TYPESCRIPT
// Integration: ChangeStreamWorker publishes to Redis instead of directly to ConnectionManager
private async processChange(change: ChangeStreamDocument): Promise<void> {
  const doc = (change as any).fullDocument
  if (!doc?.tenantId) return

  const event = {
    type: `fleet:${change.operationType}`,
    id: ++this.eventCounter,
    data: doc,
  }

  // Publish to Redis — all nodes (including this one) will receive and deliver locally
  await backplane.publish(doc.tenantId, event)
}

3.2 The Hidden Traps of Redis Pub/Sub

At-Most-Once delivery is the fundamental limitation of Redis Pub/Sub:

TEXT
Timeline:
t=0  → Node 2 starts restarting (rolling deployment)
t=1  → Redis: PUBLISH tenant:T1 (event #1042)
       → Node 1 subscriber receives ✅
       → Node 2 subscriber: DISCONNECTED — message dropped ❌
       → Node 3 subscriber receives ✅

t=5  → Node 2 restarts, reconnects to Redis
       → Node 2 missed event #1042 — no replay mechanism exists in Pub/Sub
       → Clients on Node 2 have a gap in their event stream

Redis Pub/Sub drops messages under two conditions:

  1. A subscriber is disconnected (restarting, network partition, crash) when a message is published.
  2. A subscriber's output buffer fills faster than it can consume — Redis drops the subscriber.

Client Output Buffer Limits:

BASH
# Redis config: redis.conf
# Default client output buffer for pubsub subscribers
client-output-buffer-limit pubsub 32mb 8mb 60

# This means:
# - Hard limit: 32MB → subscriber disconnected immediately if buffer exceeds 32MB
# - Soft limit: 8MB for 60 consecutive seconds → subscriber disconnected after 60s at 8MB

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.

TYPESCRIPT
// Mitigation: rate-limit broadcasts and batch multi-tenant publishes
async function batchPublish(events: Array<{ tenantId: string; event: unknown }>) {
  const pipeline = backplane.publisher.multi()
  for (const { tenantId, event } of events) {
    pipeline.publish(`tenant:${tenantId}`, JSON.stringify(event))
  }
  await pipeline.exec() // Atomic pipeline — reduces round-trips
}

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:

TEXT
Redis Pub/Sub:
PUBLISH channel message → 0 or more subscribers receive it → gone forever

Redis Streams:
XADD stream * key value → persisted to stream → consumers read at their own pace
XREADGROUP → reads from consumer group offset → explicit XACK required
Redis Pub/Sub silently drops messages during subscriber disconnects; Redis Streams guarantees at-least-once delivery with consumer group replay and XACK
Redis Pub/Sub silently drops messages during subscriber disconnects; Redis Streams guarantees at-least-once delivery with consumer group replay and XACK
TYPESCRIPT
// server/lib/RedisStreamsBackplane.ts
import { createClient } from 'redis'

const STREAM_KEY = (tenantId: string) => `events:${tenantId}`
const GROUP_NAME = 'fleet-nodes'
const MAX_STREAM_LENGTH = 10_000 // Capped stream — prevents OOM

class RedisStreamsBackplane {
  private client = createClient({ url: process.env.REDIS_URL })
  private consumerId = `node-${process.env.NODE_ID ?? randomUUID()}`

  async connect(): Promise<void> {
    await this.client.connect()
  }

  /** Publish event to the stream (called by ChangeStreamWorker) */
  async publish(tenantId: string, event: unknown): Promise<void> {
    await this.client.xAdd(
      STREAM_KEY(tenantId),
      '*', // Auto-generated ID (timestamp-based)
      { payload: JSON.stringify(event) },
      {
        // Critical: cap the stream to prevent unbounded memory growth
        TRIM: { strategy: 'MAXLEN', threshold: MAX_STREAM_LENGTH, strategyModifier: '~' },
      },
    )
  }

  /** Consumer group reader — called by each Node.js instance */
  async startConsuming(tenantId: string): Promise<void> {
    const streamKey = STREAM_KEY(tenantId)

    // Create consumer group if it doesn't exist
    try {
      await this.client.xGroupCreate(streamKey, GROUP_NAME, '0', { MKSTREAM: true })
    } catch (err) {
      if (!(err as Error).message.includes('BUSYGROUP')) throw err
    }

    // Continuous consumer loop
    while (true) {
      const results = await this.client.xReadGroup(
        GROUP_NAME,
        this.consumerId,
        { key: streamKey, id: '>' }, // '>' = only undelivered messages
        { COUNT: 100, BLOCK: 2000 }, // Block 2s waiting for new messages
      )

      for (const stream of results ?? []) {
        for (const message of stream.messages) {
          const event = JSON.parse(message.message.payload)
          connectionManager.broadcastToTenant(tenantId, event)

          // Acknowledge: removes from pending list — won't be redelivered
          await this.client.xAck(streamKey, GROUP_NAME, message.id)
        }
      }
    }
  }

  /** On restart: claim and reprocess any unacknowledged messages */
  async recoverPending(tenantId: string): Promise<void> {
    const streamKey = STREAM_KEY(tenantId)
    const CLAIM_AFTER_MS = 30_000 // Claim messages unacked for >30s

    const pending = await this.client.xAutoClaim(
      streamKey,
      GROUP_NAME,
      this.consumerId,
      CLAIM_AFTER_MS,
      '0-0', // Start from the beginning of pending entries
    )

    for (const message of pending.messages) {
      const event = JSON.parse(message.message.payload)
      connectionManager.broadcastToTenant(tenantId, event)
      await this.client.xAck(streamKey, GROUP_NAME, message.id)
    }
  }
}

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:

TYPESCRIPT
// ❌ Uncapped stream — production OOM crash waiting to happen
await redis.xAdd('events:T1', '*', { payload: data })

// At 1,000 events/second × 200 bytes/event × 86,400 seconds/day:
// = 17.28 GB/day of stream data per active tenant
// Redis is an in-memory database — this kills the server

// ✅ Capped stream with MAXLEN
await redis.xAdd('events:T1', '*', { payload: data }, {
  TRIM: {
    strategy: 'MAXLEN',
    strategyModifier: '~', // Approximate trim (faster than exact)
    threshold: 10_000,     // Keep last 10,000 events per tenant
  },
})

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:

TYPESCRIPT
// ❌ Global broadcast — every tenant receives every event
redis.publish('global:events', payload)

// ✅ Tenant-scoped channels — each node only processes events for its connected tenants
redis.publish(`tenant:${tenantId}`, payload)

// ✅ Room-scoped channels for even finer granularity
redis.publish(`room:${vehicleId}`, payload)
// Only nodes with clients subscribed to vehicleId receive the message

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)

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Distributed Systems#Redis#Nginx#Scaling#CAP Theorem#Real-Time
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.