← Back to the library
System designHow it works · Deep dive · 16 min

Running WebSockets and SSE for many users

What changes when a realtime feature goes from one server to hundreds of thousands of open connections?

At scale, realtime is a stateful fleet: connection servers hold sockets, a pub/sub backplane carries every message to every server that has an interested client, and the hard problems are slow consumers, mid-connection auth, ordering across reconnects and deploys that do not knock everyone off at once.

THE MENTAL MODEL

Split the system in two. Connection servers are dumb, horizontally scaled terminators: they authenticate, hold sockets, track which local connections want which topics, and write bytes. The backplane is the only place messages travel between machines: publishers write to a topic, and every connection server subscribed to that topic receives a copy and fans it out locally. Durability is a separate choice from fan-out: an at-most-once bus (Redis pub/sub, core NATS) is cheap and fast but forgets, so anything that must survive a reconnect needs a log (Redis Streams, JetStream, Kafka, a table) that clients can resume from by sequence number. Every connection is a promise you have to break gracefully: during deploys, when a token expires, and when a client reads slower than you write.

HOW IT FITS TOGETHER

One message from publisher to N clientsFan-out happens twice: the backplane copies to each subscribed server, and each server copies to its local sockets.
  1. Publisher (API, worker)Commits the state change first, then publishes an event with a topic (room:42, user:7) and a per-topic sequence number.
  2. Durable log (optional)Append to a Redis Stream, JetStream stream or Kafka topic so reconnecting clients can replay from their last sequence.
  3. Backplane fan-outRedis pub/sub, NATS or a stream consumer delivers a copy to every connection server subscribed to the topic.
  4. Connection server routingLooks up local subscribers in an in-memory map of topic to sockets. Servers with no subscribers should not receive the topic at all.
  5. Authorization filterDrops the message for sockets whose session expired, was revoked or lost access to the topic since they joined.
  6. Per-socket send queueBounded buffer. When it is full: drop or coalesce non-critical updates, or disconnect the slow consumer.
  7. Client apply and dedupeIgnores sequence numbers already seen, detects gaps, and asks for a replay or a fresh snapshot.
Backplane optionsPick by what you need after a disconnect: nothing (pub/sub), a short replay window (streams), or a durable history (log).
DeliveryReplay after disconnectScaling shapeWatch out for
Redis pub/subAt most once, in publish orderNone: missed while away is goneGlobal pub/sub crosses the whole cluster busSlow subscribers hit client-output-buffer limits
Redis sharded pub/sub (7.0+)At most onceNoneChannels hash to slots; add shards to scaleNo pattern subscriptions; best for many small topics
Redis StreamsAt least once with consumer groupsXREAD from an id, bounded by MAXLENOne stream per shard key; trim to cap memoryEvery server reads every entry of a shared stream
Core NATSAt most onceNoneSubject wildcards, cheap interest-based routingSlow consumer drops when pending limits fill
NATS JetStreamAt least once, optional dedupe windowFrom sequence or time, per streamStreams replicated via RaftExtra latency and storage vs core NATS
KafkaAt least once per partition, in orderAny retained offset, days or foreverPartitions; consumer groups per servicePoor fit for millions of tiny per-user topics
Managed (Ably, Pusher)Provider-definedAbly: about 2 min resume plus historyProvider scales connections and fan-outPer-message pricing; Pusher caps events at 10 KB
Draining a connection server without a reconnect stormThe goal is that the fleet sees reconnects spread over tens of seconds, not all at once.
  1. Orchestrator → Old conn serverSIGTERMterminationGracePeriodSeconds must exceed the whole drain.
  2. Old conn server → Load balancerReadiness fails (503)No new connections are routed here; existing ones keep working.
  3. Old conn server → Clients (10k)Close 1012 over 30 s, random orderSome protocols send a reconnect hint first so clients move before the close.
  4. Clients (10k) → Load balancerReconnect after jittered backoffFull jitter: random delay up to the backoff cap, so retries do not align.
  5. Load balancer → Other serversRoute to healthy servers
  6. Clients (10k) → Other serversresume: token + lastSeq
  7. Other servers → Clients (10k)Replay missed events, then liveRead from the durable log; the old server's memory is gone.
  8. Old conn server → Clients (10k)terminate() stragglers, exit

KEY TERMS

Connection capacity
Each open connection costs a file descriptor, kernel socket buffers, TLS state and your per-connection objects, often tens of KB before application data; permessage-deflate can add far more. Raise the process fd limit (LimitNOFILE, ulimit -n) well above expected connections, size socket buffers and accept backlogs deliberately, and remember a proxy talking to one upstream address is limited by its ephemeral port range per upstream IP and port. Load-test with realistic idle-to-active ratios: idle sockets are cheap, broadcasts are not.
Load balancing and stickiness
Once upgraded, a WebSocket stays on one server, so stickiness only matters for multi-request transports: Socket.IO's HTTP long-polling fallback requires sticky sessions or it returns 400s. Prefer stateless connection servers: any server can accept any client because session state lives in a shared store and messages arrive through the backplane. Balance on active connections, not requests, or new servers stay empty after a deploy while old ones stay full.
Backplane
The bus between publishers and connection servers. Redis pub/sub and core NATS are at-most-once: whatever a server misses while disconnected is gone. Redis Streams, JetStream and Kafka keep a log you can read from an offset, which is what makes resume possible. Many teams run both: a fast pub/sub path for live delivery and a log for replay on reconnect.
Fan-out shape and presence
Topics usually map to rooms or channels (room:42) and per-user topics (user:7, for notifications across devices). Subscribe a server to a topic only while it has a local subscriber, or every server receives every message. A room with a million members is a celebrity fan-out problem: batch or coalesce, sample, or move it to a broadcast tier. Presence is soft state: heartbeat-refreshed entries with a TTL (Redis keys or sorted sets), published as diffs, never derived from close events alone because crashes send none.
Ordering, delivery and slow consumers
You only get ordering per topic from one publisher through one ordered path; across topics and reconnects, assign a per-topic sequence number and let clients dedupe and detect gaps. Delivery to a browser is at most once unless the client acks or resumes from a cursor. A client reading slower than you publish grows its send queue (ws bufferedAmount, NATS pending limits) until memory runs out, so pick a policy per message type: drop or coalesce for ephemeral state like cursors and prices, buffer up to a cap for important events, then disconnect so the client resyncs from a snapshot.
Auth on long-lived connections
A socket authenticated at connect time outlives the token that opened it. Store the session id and expiry on the connection, re-check on a timer or on sensitive actions, let clients send a fresh token in-band before expiry, and subscribe each server to a revocation topic so logout, password change or role removal closes affected sockets (WebSocket close 1008) within seconds. Re-check topic permissions on resubscribe after every reconnect.
Deploys, draining and observability
Every deploy disconnects every client on the replaced servers. Fail readiness first, then close connections gradually with 1012 (service restart) or 1001, and make clients reconnect with exponential backoff and full jitter so the fleet is not hit by a synchronized storm. Watch open connections per server, connects and disconnects per second by close code, backplane lag (publish to socket write), send-queue depth, dropped and slow-consumer counts, and replay versus snapshot ratios.

IN YOUR STACK

TypeScript · Socket.IO + Redis Streams Server

import { createServer } from "node:http";
import { Server } from "socket.io";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-streams-adapter";

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

const httpServer = createServer();
const io = new Server(httpServer, {
  adapter: createAdapter(redis, { maxLen: 100_000 }), // replay window shared by all nodes
  connectionStateRecovery: { maxDisconnectionDuration: 2 * 60_000, skipMiddlewares: false }, // re-auth on resume
});
io.use(async (socket, next) => {
  const user = await verifyToken(socket.handshake.auth.token);
  if (!user) return next(new Error("unauthorized"));
  socket.data.user = user;
  next();
});
io.on("connection", (socket) => {
  // Recovered sockets get their rooms, data and missed packets back; register handlers either way.
  if (!socket.recovered) socket.join([`user:${socket.data.user.id}`, `org:${socket.data.user.orgId}`]);
});
export const notifyOrg = (orgId: string, event: unknown) => io.to(`org:${orgId}`).emit("event", event);
httpServer.listen(3000);
  • Use the Redis Streams adapter, not the pub/sub Redis adapter, if you want connection state recovery: packets persisted in a stream survive Redis hiccups and brief client disconnects; the pub/sub adapter cannot replay.
  • Recovery covers short gaps (here 2 minutes). Longer absences, or socket.recovered === false, must be handled by refetching state over HTTP.
  • skipMiddlewares defaults to true, which lets a socket whose token was revoked during the gap resume without re-authentication. false re-runs io.use() and still restores state on success.
  • If the HTTP long-polling transport is enabled you still need sticky sessions at the load balancer; with transports: ["websocket"] you do not.
Socket.IO + Redis Streams documentation (opens in new tab)

JavaScript · Node.js ws + Redis pub/sub Server

import { WebSocketServer } from "ws";
import { createClient } from "redis";

const sub = await createClient({ url: process.env.REDIS_URL }).connect();
const wss = new WebSocketServer({ port: 8080, maxPayload: 64 * 1024 });
const rooms = new Map(); // room -> Set<WebSocket>, local to this node only

async function join(ws, room) {
  if (!rooms.has(room)) {
    rooms.set(room, new Set());
    await sub.subscribe(`room:${room}`, (msg) => fanOut(room, msg)); // first local member
  }
  rooms.get(room).add(ws);
  ws.once("close", () => leave(ws, room));
}
async function leave(ws, room) {
  const members = rooms.get(room);
  if (members?.delete(ws) && members.size === 0) { rooms.delete(room); await sub.unsubscribe(`room:${room}`); }
}
function fanOut(room, msg) {
  for (const ws of rooms.get(room) ?? []) // over 1 MiB queued: disconnect, client resyncs
    ws.bufferedAmount > 1 << 20 ? ws.close(1013, "slow consumer") : ws.send(msg);
}
wss.on("connection", (ws, req) => join(ws, roomFor(req))); // roomFor: authenticated room lookup
  • Publishers anywhere run PUBLISH room:<id> <json>; Redis delivers to every node subscribed to that channel, at most once.
  • Subscribing per room only while a local member exists keeps unrelated traffic off each node. With Redis Cluster, SSUBSCRIBE/SPUBLISH (sharded pub/sub) avoids broadcasting every message across the cluster bus.
  • Pub/sub alone loses messages during a node or Redis reconnect. Put a sequence number in each message and serve gaps from a log.
Node.js ws + Redis pub/sub documentation (opens in new tab)

Go · nats.go (core NATS) Server

nc, err := nats.Connect(os.Getenv("NATS_URL"),
	nats.MaxReconnects(-1),
	nats.ReconnectWait(2*time.Second),
	nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, err error) {
		if errors.Is(err, nats.ErrSlowConsumer) {
			slowConsumer.Inc() // this node dropped backplane messages
		}
	}),
)
if err != nil {
	log.Fatal(err)
}
defer nc.Drain()

sub, err := nc.Subscribe("user.*.events", func(m *nats.Msg) {
	userID := strings.Split(m.Subject, ".")[1]
	for _, c := range hub.Conns(userID) { // this node's sockets for that user
		select {
		case c.Out <- m.Data: // bounded per-connection channel drained by a writer goroutine
		default:
			c.Close("slow consumer") // never block the NATS callback
		}
	}
})
sub.SetPendingLimits(100_000, 64<<20)
  • Publishers call nc.Publish("user.42.events", payload). A single wildcard subscription is simple but sends every user's events to every node. With many nodes, subscribe per user (user.42.events) when their first socket connects and unsubscribe when the last leaves.
  • Core NATS is at-most-once. Use JetStream for the replay log, or let clients refetch on reconnect.
  • Queue groups load-balance one copy to one member: right for workers, wrong for fan-out to connection servers.
nats.go (core NATS) documentation (opens in new tab)

TypeScript · Cloudflare Durable Objects Server

import { DurableObject } from "cloudflare:workers";

export class Room extends DurableObject {
  async fetch(req: Request) {
    if (req.headers.get("Upgrade") !== "websocket") return new Response("Expected WebSocket", { status: 426 });
    const user = req.headers.get("X-User-Id")!; // set by the Worker after it verified the session
    const { 0: client, 1: server } = new WebSocketPair();
    this.ctx.acceptWebSocket(server, [`user:${user}`]); // hibernatable; tags find sockets later
    server.serializeAttachment({ user }); // survives hibernation, max 16 KiB
    return new Response(null, { status: 101, webSocket: client });
  }

  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) {
    const { user } = ws.deserializeAttachment();
    const seq = ((await this.ctx.storage.get<number>("seq")) ?? 0) + 1;
    await this.ctx.storage.put("seq", seq);
    const out = JSON.stringify({ seq, user, body: String(message) });
    for (const peer of this.ctx.getWebSockets()) peer.send(out);
  }

  async kick(userId: string) { // RPC from the Worker on logout or revocation
    for (const ws of this.ctx.getWebSockets(`user:${userId}`)) ws.close(1008, "session revoked");
  }
}
  • One object per room is a single-threaded coordinator with storage, so ordering and sequence numbers come for free; up to 32,768 hibernatable WebSockets per object, fewer in practice depending on CPU and memory.
  • While hibernated, sockets stay open on Cloudflare's network and duration is not billed; in-memory fields are reset and the constructor runs again on the next event. setWebSocketAutoResponse answers heartbeat pings without waking the object.
  • Authenticate in the Worker before forwarding to the object: requests to both are billed, and the object should only see verified upgrades.
Cloudflare Durable Objects documentation (opens in new tab)

TypeScript · Node.js http + ws Server

import { createServer } from "node:http";
import { WebSocketServer } from "ws";

let draining = false;
const server = createServer((req, res) => {
  if (req.url === "/readyz") return res.writeHead(draining ? 503 : 200).end();
  res.writeHead(404).end();
});
const wss = new WebSocketServer({ server });
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

process.once("SIGTERM", async () => {
  draining = true;  // readiness fails: the LB stops sending new upgrades here
  await sleep(10_000); // longer than health-check interval x failure threshold
  server.close();   // stop accepting; upgraded sockets are not closed by this
  const spread = 30_000;
  for (const ws of wss.clients) {
    // Random order and time, so the rest of the fleet sees a ramp, not a spike.
    setTimeout(() => ws.close(1012, "service restart"), Math.random() * spread);
  }
  await sleep(spread + 5_000);
  for (const ws of wss.clients) ws.terminate(); // peers that never finished the close handshake
  process.exit(0);
});
server.listen(8080);
  • The whole sequence takes about 45 s here, so Kubernetes terminationGracePeriodSeconds (default 30) must be raised, or the pod is killed mid-drain.
  • server.close() and closeAllConnections() skip upgraded sockets; you must close WebSockets yourself, and wss.close() does not close existing clients either.
  • Surge capacity first: draining 10% of the fleet moves 10% of connections onto the remaining servers.
Node.js http + ws documentation (opens in new tab)

TypeScript · Browser WebSocket Client

let attempt = 0, lastSeq = 0, resumeToken = "";

async function connect() {
  let ticket;
  try { ticket = await fetchTicket(); } // short-lived, single-use, from an authenticated HTTP call
  catch { return retry(); }
  const ws = new WebSocket(`wss://rt.example.com/ws?ticket=${ticket}`);
  ws.onopen = () => { attempt = 0; ws.send(JSON.stringify({ type: "resume", token: resumeToken, after: lastSeq })); };
  ws.onmessage = ({ data }) => {
    const msg = JSON.parse(data);
    if (msg.type === "session") resumeToken = msg.token;
    else if (msg.type === "reset") reloadSnapshot(); // server cannot replay from lastSeq
    else if (msg.seq > lastSeq) { lastSeq = msg.seq; apply(msg); } // drop duplicates
  };
  ws.onclose = (e) => (e.code === 1008 ? showSignedOut() : retry()); // revoked: do not hammer
}

function retry() {
  // Full jitter: uniform in [0, min(cap, base * 2^attempt)].
  const delay = Math.random() * Math.min(30_000, 500 * 2 ** attempt++);
  setTimeout(() => (navigator.onLine ? connect() : addEventListener("online", connect, { once: true })), delay);
}
connect();
  • Resetting attempt only on a successful open keeps a flapping server from being retried at full speed.
  • The resume token identifies the session on the server; lastSeq says where to replay from. A reset reply means the backlog no longer covers the gap, so refetch over HTTP.
  • A ticket in the URL is acceptable only because it is single-use and expires in seconds; never put long-lived tokens there.
Browser WebSocket documentation (opens in new tab)

WHEN TO REACH FOR WHAT

Single node, no backplane

Use when Up to tens of thousands of connections that fit one process, internal tools, or a first version. In-memory rooms are fine.

Avoid when You need more than one instance for availability or deploys without disconnecting everyone; the in-memory map stops being the truth.

Build: Redis backplane

Use when You already run Redis, topics are rooms or users, and a short replay window (Streams with MAXLEN) or client refetch covers gaps.

Avoid when You need long durable history, or broadcast volume through one Redis primary becomes the bottleneck.

Build: NATS or Kafka backplane

Use when NATS for interest-based routing across many subjects with JetStream for replay; Kafka when events already live there and ordered, retained history matters.

Avoid when Your team does not already operate it. Kafka in particular is a poor fit for millions of tiny per-user topics.

Cloudflare Durable Objects

Use when Natural per-room or per-document coordination, global users, and spiky or idle-heavy traffic where hibernation keeps costs low.

Avoid when One room needs more than one object's throughput (about 1,000 requests per second soft limit) or you must stay off Cloudflare.

AWS API Gateway WebSockets

Use when You are serverless on AWS: connections are held for you and routes invoke Lambda; you push via the @connections API.

Avoid when Connections must live longer than 2 hours, idle more than 10 minutes, or carry messages over 128 KB; broadcasts mean one API call per connection.

Buy: Ably, Pusher and similar

Use when Realtime is a feature, not your product: you want global edge, presence, history and SDKs now, and per-message pricing fits your volume.

Avoid when Message volume is huge and predictable, data residency or latency rules out a third party, or the protocol is core IP.

WHERE IT BITES

  • Broadcasting from in-process memory: works on one node, silently misses clients on the others once you scale out. Every cross-client message must go through the backplane.
  • Unbounded send queues. One slow mobile client can buffer megabytes per socket; at scale this is an outage. Cap queue depth and choose drop, coalesce or disconnect per message type.
  • Deploys that close every socket at once, and clients that reconnect immediately or with fixed delays. The synchronized storm overloads auth, the backplane and replay. Drain gradually and use full-jitter backoff.
  • Authenticating only at connect time. Revoked users keep receiving data for as long as the socket lives. Track expiry per connection and subscribe to a revocation topic.
  • Treating an at-most-once backplane as reliable. Redis pub/sub and core NATS drop messages during reconnects or slow consumption; without sequence numbers and replay, clients drift from the truth.
  • Hitting platform limits in production: process fd limits, LB idle timeouts (ALB defaults to 60 s and ignores HTTP/2 PINGs), API Gateway's 2-hour cap and 10-minute idle timeout. Heartbeat below the shortest one and expect forced reconnects.

CLOSE THE AI. EXPLAIN THIS.

You run 20 connection servers with 50k sockets each behind a load balancer and Redis pub/sub. A deploy rolls them one at a time and every rollout causes a CPU spike and lost messages. Trace one message from publisher to client, then explain what you would change in the drain, the client reconnect, the backplane and the client's resume protocol.

SOURCES

Explainer reviewed