Siddhant Deval
Siddhant Deval
backend16 min read

Real-Time Communication: WebSockets, SSE & HTTP/3 at Production Scale

WebSockets, SSE, and HTTP/3 each solve a different real-time problem — full-duplex state sync, efficient server push, and transport-layer resilience — and using the wrong primitive creates horizontal scaling problems that appear only under load. This article builds WebSocket server architecture from connection lifecycle through Redis PubSub fan-out, contrasts SSE for one-way push, and explains why HTTP/3's QUIC eliminates the head-of-line blocking that makes TCP unsuitable for multiplexed real-time APIs.

Real-Time Communication: WebSockets, SSE & HTTP/3 at Production Scale

Senior engineers don't just wire services together — they design the boundary: the contract, the trust model, the failure envelope, and the signal pipeline that proves it's working. Real-time communication is where the failure envelope is most exposed: a WebSocket server that works perfectly under test will exhaust file descriptors when ten thousand users connect simultaneously because no one counted the connections. An SSE endpoint that streams updates correctly on a single node silently breaks when a second node is added because the events originate on whichever node processed the write, not the node holding the client's connection. HTTP/3 eliminates a class of head-of-line blocking failures that engineers have been working around for twenty years — and most teams do not know when to adopt it. This article derives each primitive from the scaling constraint it solves.

Architectural Note

Series positioning: This is Part 4 of the API Architecture & System Resilience series. It builds on the API Gateway from Part 2. The GraphQL-specific WebSocket and SSE transport patterns are covered in the separate GraphQL Backend & API Design series — this article focuses on general-purpose server-side real-time architecture.


1. WebSocket Architecture: Connection Lifecycle & Memory Model

1.1 The Upgrade Handshake

HTTP
GET /realtime HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

After the 101 Switching Protocols response, the HTTP connection is replaced by a bidirectional TCP stream. No more HTTP request/response cycle — both ends can write at any time.

1.2 Per-Connection Memory Planning

TYPESCRIPT
// ❌ No connection limit — memory exhausts silently at ~65k connections
import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })
wss.on('connection', (ws) => {
  // Each connection: ~4-8KB OS TCP buffer + application state
  // At 50,000 connections: 200–400MB memory consumed by sockets alone
  // Plus Node.js event loop entries, listeners, per-connection data structures
  ws.on('message', (data) => { /* ... */ })
})

// ✅ Explicit capacity planning with connection limit
const MAX_CONNECTIONS = parseInt(process.env.MAX_WS_CONNECTIONS ?? '10000', 10)
const wss = new WebSocketServer({ port: 8080, maxPayload: 65536 }) // 64KB max message

wss.on('connection', (ws, req) => {
  if (wss.clients.size > MAX_CONNECTIONS) {
    ws.close(1008, 'Connection limit reached')
    return
  }

  const connectionId = crypto.randomUUID()
  const userId = req.headers['x-user-id'] as string

  // Heartbeat: detect dead connections (TCP half-open)
  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })

  ws.on('close', () => {
    // Clean up per-connection state immediately — no GC dependency
    connectionRegistry.delete(connectionId)
  })
})

// Heartbeat interval — terminate connections that stop responding
const heartbeat = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) {
      ws.terminate()  // SIGKILL the connection — frees the file descriptor immediately
      return
    }
    ws.isAlive = false
    ws.ping()  // Expects a pong within the next interval
  })
}, 30_000)

wss.on('close', () => clearInterval(heartbeat))
Crucial Requirement

TCP half-open connections — where the client disconnects without sending a FIN (e.g., a mobile device losing signal) — remain in the server's connection table consuming a file descriptor indefinitely unless the server detects them via application-level heartbeat. The ping/pong mechanism is mandatory for production WebSocket servers.


2. Horizontal Scaling: From Sticky Sessions to Redis PubSub

2.1 Why Sticky Sessions Fail Under Load

Node 1: 3,000 connections (users A, B, C, ...)
Node 2: 3,000 connections (users D, E, F, ...)
Node 3: 4,000 connections (users G, H, I, ...)

Event: "Order shipped for user B" — processed by Order Service → publishes to Node 1
Node 1 delivers to user B's WebSocket ✅

Event: "Order shipped for user D" — processed by Order Service → publishes to Node 1
Node 1 looks for user D's connection → not found ❌ (user D is on Node 2)
Message is dropped silently.

2.2 Redis PubSub Adapter

TYPESCRIPT
// ✅ Redis PubSub — all nodes subscribe to the same channel
import { createClient } from 'redis'
import { WebSocketServer } from 'ws'

const publisher = createClient({ url: process.env.REDIS_URL })
const subscriber = publisher.duplicate()

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

// Each WebSocket node subscribes to all relevant channels
const connectionsByUserId = new Map<string, Set<WebSocket>>()

await subscriber.subscribe('orders:updates', (message) => {
  const event = JSON.parse(message) as { userId: string; payload: unknown }

  // Find this user's connections on THIS node
  const userConnections = connectionsByUserId.get(event.userId)
  if (!userConnections) return // User not connected to this node — no-op, another node handles it

  userConnections.forEach((ws) => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify(event.payload))
    }
  })
})

// When the Order Service ships an order, it publishes to Redis
// ALL WebSocket nodes receive the message and deliver to their local connections for that user
async function publishOrderUpdate(userId: string, orderEvent: OrderEvent) {
  await publisher.publish('orders:updates', JSON.stringify({ userId, payload: orderEvent }))
}
WebSocket horizontal scaling flow trace: order-shipped event published to Redis PubSub channel; all three WebSocket nodes receive the message; only Node 2 (which holds user D's connection) delivers to the WebSocket client — Nodes 1 and 3 find no matching connection and no-op.
WebSocket horizontal scaling flow trace: order-shipped event published to Redis PubSub channel; all three WebSocket nodes receive the message; only Node 2 (w…

3. STOMP over WebSocket

STOMP (Simple Text Oriented Messaging Protocol) adds a message-level protocol on top of raw WebSocket frames — subscription channels, acknowledgment semantics, and receipt confirmation.

TYPESCRIPT
// ✅ STOMP server with @stomp/stompjs on the server side
import { Server } from '@stomp/stompjs'
import { WebSocketServer } from 'ws'

const wss = new WebSocketServer({ port: 8080 })

const stompServer = new Server({
  webSocketServer: wss,

  onConnect(frame) {
    const userId = frame.headers['x-user-id']
    console.log(`STOMP CONNECT from user ${userId}`)
    return { headers: { 'server': 'OrderService/1.0' } }
  },

  onSubscribe(subscription, frame) {
    // Client subscribes to /user/orders or /topic/announcements
    const destination = frame.headers['destination']

    if (destination.startsWith('/user/')) {
      // Per-user subscription — route to this connection only
      registerUserSubscription(subscription, destination)
    } else if (destination.startsWith('/topic/')) {
      // Broadcast subscription — route to all subscribers
      registerTopicSubscription(subscription, destination)
    }
  },
})

STOMP is the correct choice when clients need:

  1. Multiple logical channels over a single WebSocket connection (e.g., /user/orders, /user/notifications, /topic/system-announcements)
  2. Acknowledgment-based delivery — clients acknowledge receipt; server retransmits unacknowledged frames
  3. Message receipts — clients can request a receipt for a specific send operation

4. Server-Sent Events (SSE): Efficient One-Way Push

TYPESCRIPT
// ✅ SSE endpoint — standard HTTP response, no WebSocket upgrade needed
app.get('/events/orders/:userId', (req, res) => {
  const userId = req.params.userId

  // SSE requires these exact headers
  res.setHeader('Content-Type', 'text/event-stream')
  res.setHeader('Cache-Control', 'no-cache')
  res.setHeader('Connection', 'keep-alive')
  res.setHeader('X-Accel-Buffering', 'no')  // Disable NGINX buffering — critical for SSE
  res.flushHeaders()  // Send headers immediately — opens the stream

  // Send initial comment to establish connection (also resets reconnect timeout)
  res.write(': connected\n\n')

  // Send a named event with a last-event-id for reconnect support
  let lastEventId = 0

  function sendEvent(eventType: string, data: unknown) {
    lastEventId++
    res.write(`id: ${lastEventId}\n`)
    res.write(`event: ${eventType}\n`)
    res.write(`data: ${JSON.stringify(data)}\n\n`) // Double newline terminates the event
  }

  // Subscribe to Redis for this user's events
  const redisSubscriber = redis.duplicate()
  redisSubscriber.subscribe(`sse:user:${userId}`, (message) => {
    const event = JSON.parse(message)
    sendEvent(event.type, event.payload)
  })

  // Heartbeat — prevents proxy timeouts (many proxies close idle connections after 60s)
  const heartbeat = setInterval(() => {
    res.write(': heartbeat\n\n')
  }, 25_000)

  req.on('close', () => {
    clearInterval(heartbeat)
    redisSubscriber.unsubscribe()
    redisSubscriber.quit()
  })
})

4.1 Client-Side Reconnect via Last-Event-ID

JAVASCRIPT
// Browser client — EventSource handles reconnection automatically
const es = new EventSource('/events/orders/usr_abc')

es.addEventListener('order.shipped', (event) => {
  console.log('Order shipped:', JSON.parse(event.data))
})

es.addEventListener('order.delivered', (event) => {
  console.log('Order delivered:', JSON.parse(event.data))
})

// On reconnect, the browser sends:
// GET /events/orders/usr_abc
// Last-Event-ID: 42
// The server uses this to replay events after event ID 42
// Enabling exactly-once delivery across reconnects

5. WebSocket vs SSE Decision Framework

Criterion WebSocket SSE
Communication direction Full-duplex (both ends can send at any time) Server → client only
Protocol overhead Custom frame framing overhead Pure HTTP — no extra protocol
Proxy compatibility Requires proxy WebSocket support Works through any HTTP proxy
Load balancer Requires sticky sessions or Redis adapter Stateless — any instance handles it
Browser reconnect Manual (must implement reconnect logic) Automatic (EventSource spec)
Server implementation Complex (upgrade handshake, ping/pong, frame framing) Simple (chunked HTTP response)
Use when Chat, collaborative editing, gaming, bidirectional control Notifications, live feeds, dashboards, progress streaming
Pro Tip & Optimization

If your real-time use case is purely server-to-client (notifications, live data feeds, progress updates), SSE is strictly superior to WebSockets: simpler server implementation, no upgrade handshake, automatic reconnection, and full HTTP proxy compatibility. WebSockets are only necessary when the client must also send data to the server over the same persistent connection.


6. HTTP/3 & QUIC: Eliminating Head-of-Line Blocking

6.1 TCP Head-of-Line Blocking in HTTP/2

HTTP/2 over TCP: Multiplexed streams on a single TCP connection

Stream 1: [frame 1] [frame 2] [frame 3] ...
Stream 2: [frame 1] [frame 2] [frame 3] ...
Stream 3: [frame 1] [frame 2] [frame 3] ...

If frame 2 of Stream 1 is lost in transit:
- TCP must wait for retransmission of the lost frame
- ALL streams (1, 2, 3) are blocked — even though Streams 2 and 3 have no missing data
- This is TCP head-of-line blocking at the transport layer
- HTTP/2 multiplexing works at the application layer; TCP serializes bytes at the transport layer

6.2 QUIC: Independent Streams Over UDP

HTTP/3 over QUIC: Independent streams, each with its own flow control

Stream 1: [frame 1] [frame 2 LOST] [frame 3] ...
Stream 2: [frame 1] [frame 2] [frame 3] ...
Stream 3: [frame 1] [frame 2] [frame 3] ...

If frame 2 of Stream 1 is lost:
- Only Stream 1 is retransmitted — Streams 2 and 3 continue without interruption
- Each QUIC stream has independent sequencing and flow control
- Loss on one stream does not affect any other stream on the same connection

6.3 0-RTT Connection Establishment

HTTP/1.1 / HTTP/2 over TCP: 1–3 RTTs before data
  RTT 1: TCP SYN / SYN-ACK
  RTT 2: TLS ClientHello / ServerHello
  RTT 3: TLS Finished / Application Data begins

HTTP/3 over QUIC + TLS 1.3 (first connection): 1 RTT
  RTT 1: QUIC Initial (combines TCP SYN + TLS ClientHello)
          Server sends QUIC Handshake + CertificateVerify
  RTT 0 (data): Client can send 0-RTT data with session resumption
               (server verifies before processing — replay protection)
Architectural Note

0-RTT data in QUIC is replay-vulnerable by design — an attacker who captures a 0-RTT packet can replay it. Never use 0-RTT for non-idempotent requests (POST, DELETE). It is appropriate for GET requests and safe reads only. Most QUIC implementations require explicit opt-in for 0-RTT on per-route basis.

6.4 QUIC Connection Migration

Mobile client transitions from WiFi (192.168.1.5) to LTE (10.0.0.12):

HTTP/2 over TCP: Connection is identified by (src-ip, src-port, dst-ip, dst-port)
  IP change → connection ID changes → TCP connection drops → reconnect required
  All in-flight requests are lost; TLS handshake must be repeated

HTTP/3 over QUIC: Connection is identified by a stable Connection ID (random bytes)
  IP change → client sends new packet with the same Connection ID → server accepts
  Connection migrates transparently — no reconnect, no handshake repeat
  In-flight requests continue uninterrupted
HTTP/1.1 vs HTTP/2 vs HTTP/3 comparison matrix across connection model, head-of-line blocking behavior, TLS version, 0-RTT support, multiplexing mechanism, connection migration, and optimal use case.
HTTP/1.1 vs HTTP/2 vs HTTP/3 comparison matrix across connection model, head-of-line blocking behavior, TLS version, 0-RTT support, multiplexing mechanism, c…

6.5 When HTTP/3 Matters vs When It Is Premature Optimization

Scenario HTTP/3 Impact
Mobile APIs on lossy networks High — packet loss stalls TCP; QUIC streams are independent
High-multiplex APIs (many parallel requests) High — eliminates HOL blocking across all concurrent streams
Video streaming / media delivery High — connection migration survives handoffs
Datacenter-only APIs (low packet loss) Low — TCP HOL blocking rarely triggers; QUIC overhead exceeds benefit
Simple request/response APIs (< 5 concurrent requests) Low — multiplexing advantage not realized
APIs behind CDN Medium — CDN terminates HTTP/3; origin is still HTTP/2

Summary

Primitive Rule
WebSocket connections Count file descriptors; implement heartbeat (ping/pong); hard-cap MAX_CONNECTIONS
WebSocket horizontal scaling Redis PubSub adapter — never sticky sessions; sticky fails silently on node death
STOMP over WebSocket Use when clients need multiple logical channels or acknowledgment semantics
SSE Prefer over WebSocket for server-push; simpler, proxy-transparent, auto-reconnect
SSE reconnect Return Last-Event-ID in stream; server replays missed events on reconnect
HTTP/3 adoption Prioritize for mobile APIs, lossy networks, high-multiplexing; skip for datacenter-only
0-RTT Never for non-idempotent requests — replay attack surface
QUIC migration Enables seamless WiFi→LTE transitions without reconnect for mobile clients

What's Next

In Part 5, we turn to the identity layer — Part 5: JWT, OIDC & RBAC builds the complete authentication architecture: how JWTs are verified statlessly at microservice boundaries, how OIDC federates identity for SSO, and how RBAC role claims flow from the gateway to every downstream service without duplicating authorization logic.

Research & Synthesis Note

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

#WebSockets#SSE#HTTP/3#QUIC#Real-Time#Node.js
Siddhant Deval

Written by Siddhant Deval

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