WebSocket scaling past 10k concurrent: Durable Objects, Redis pub/sub, and PartyKit
A collab feature that worked on 200 users took down the API gateway at 9,400. The fan-out, sticky-session, and presence patterns that actually scale past the magic 10k mark.
It's a Thursday afternoon. Your team's collab feature has been in production for six weeks, holding steady at 150–200 concurrent users during peak hours, p95 latency under 80ms, zero pages. Then marketing runs a campaign, a large organization signs up, and their 900-person engineering org decides to onboard the same afternoon. At 9,400 concurrent WebSocket connections the API gateway starts returning 503s. The chat floods with "is it down?" By the time the on-call engineer gets to a laptop, the connection count has rolled back to 3,000 and the gateway has recovered. Nobody knows exactly what broke.
This is not a war story about a particular company. It is the most common failure trajectory for WebSocket-backed collaborative features: works beautifully at 200, quietly disintegrates somewhere between 2,000 and 15,000, and does it in a way that is hard to reproduce locally because local test environments never simulate the fan-out topology, the connection-level back-pressure, or the ephemeral connection storms that characterize a real concurrent load spike.
The post that follows is a practitioner's map of the failure modes and the three primary architectures used to scale past them: stateless app servers with Redis Pub/Sub for broadcast fan-out, Cloudflare Durable Objects for room-per-instance coordination, and PartyKit as an abstraction layer over Durable Objects that removes most of the coordination boilerplate. It includes a rough cost comparison across all three at 100k concurrent connections, because the architecture decision and the billing model are coupled in ways that surprise teams the first time they see an invoice.
The post assumes you have already shipped something. The audience is engineers who have a WebSocket service in production or close to it, who understand the difference between a single-server prototype and a horizontally scaled deployment, and who want to understand what actually breaks and why before it breaks in production.
The prior post in this series covers CRDT-based conflict resolution and comment threading — this one is exclusively about transport infrastructure and connection scaling. Those two concerns are orthogonal enough that they deserve separate treatment.
What's actually happening
The transport layer is not the bottleneck you think it is
Most engineers who have scaled WebSocket services discover that the WebSocket protocol itself is rarely the limiting factor. A single Node.js process can hold tens of thousands of idle WebSocket connections with modest memory overhead — the ws library benchmarks from around 2023 showed roughly 5–7MB per 1,000 idle connections under Node.js 20 on a 1-vCPU instance, which means 100,000 idle connections fit in under 700MB. Memory is cheap.
The bottleneck is almost always one of three things: fan-out amplification, connection affinity, or ephemeral storms. Understanding which one is killing your service is the starting point for choosing the right architecture.
Fan-out amplification happens when a single event must be delivered to many connections. One user sends a message in a room with 10,000 subscribers. Your backend receives one write. It must now produce 10,000 outbound WebSocket frames. If those 10,000 connections are distributed across 20 application server instances (500 per instance on average), the write instance publishes one event to Redis Pub/Sub. All 20 instances receive it. Each one iterates its local subscriber set and calls ws.send() 500 times. The arithmetic is straightforward: at 1KB per event, a single message generates 10MB of outbound network traffic distributed across your fleet. At 100 messages per second room-wide, that is 1GB/s of aggregate egress. Egress pricing notices that.
The amplification problem compounds when the room is unevenly distributed. If one application instance happens to hold 3,000 of the 10,000 connections (due to non-uniform load balancing during a connection storm), that one instance is handling 30% of the fan-out workload. Under load, it will saturate its CPU or outbound network queue before the others, causing back-pressure to propagate upstream. From the outside this looks like a subset of users experiencing delivery lag — not a clean error, just a slow stream that is impossible to distinguish from a network hiccup.
Connection affinity is a constraint imposed by horizontal scaling. A WebSocket connection is a stateful, long-lived TCP connection. Unlike HTTP requests, it cannot be load-balanced to a different server mid-connection. When you add a second application server instance, connections made after the scale-out event go to either instance randomly (or according to your load balancer's algorithm), but existing connections stay pinned to their originating instance. If instance A holds a connection from user X and instance B holds a connection from user Y, and Y sends a message that should reach X, instance B cannot directly reach X's connection. It needs a shared communication channel — typically Redis Pub/Sub — to route the message across the instance boundary.
Sticky sessions (session affinity at the load balancer layer, implemented via a cookie or consistent IP hashing) solve the single-server case but create a different problem: they concentrate connections on specific instances in ways that can create hot spots. If your consistent hash function assigns a popular room's users disproportionately to one instance, that instance becomes overloaded while others sit idle. HAProxy and nginx both support consistent hash-based stickiness, but the hash key needs to be the room ID, not the user ID or IP, to achieve room-level affinity. Even then, this only helps if you want to avoid the Redis fan-out cost, not if you want horizontal elasticity.
Ephemeral connection storms are the least understood failure mode. A connection storm happens when many clients attempt to reconnect simultaneously. The most common trigger is a server restart during a rolling deploy. In a naive WebSocket client implementation, when the TCP connection drops, every client immediately attempts to reconnect. With 10,000 concurrent connections, this means 10,000 simultaneous connect() calls within a 100–500ms window (depending on TCP RST propagation latency). Each reconnect requires a TLS handshake, authentication token validation, subscription re-registration, and historical event replay. The database connection pool, the Redis subscriber connection setup, and the authentication service all receive a concurrent burst they were not sized for.
A jittered exponential backoff on the client — starting at 1 second base delay with ±50% random jitter, capped at 60 seconds — spreads a 10,000-connection reconnect storm from a 500ms window to roughly a 10-minute ramp. The mathematical distribution of reconnects over time changes from a step function to something closer to a Poisson arrival rate, which your infrastructure handles gracefully. This is not a server-side fix. It is a client-side discipline that must be established before scale, not retrofitted after the first storm.
The fan-out math at scale
It is worth being concrete about the numbers before comparing architectures. Assume the following scenario: 10,000 concurrent connections, 50 active rooms, rooms averaging 200 users each, average message rate of 5 messages per second per room.
At 5 messages/second × 50 rooms = 250 messages/second total. Each message must be delivered to 200 connections. That is 50,000 WebSocket frame sends per second across the fleet. At 1KB per message payload, aggregate outbound bandwidth is 50MB/s — about 400 Mbps. On a 1 Gbps network interface, this is well within headroom. On a shared cloud instance with a 250 Mbps soft limit on outbound throughput, it is already over half your budget.
The Redis Pub/Sub amplification factor adds a twist: with 5 application server instances each subscribed to each of the 50 room channels, each published message is received 5 times (once per instance), even though 4 of those 5 instances have zero connections watching that room at that moment. Redis still delivers the event to all subscribers regardless of whether they have local connections in that room. The fix is lazy subscription: subscribe to a room's Redis channel only when the first local connection for that room arrives, and unsubscribe when the last local connection for that room leaves. This reduces unnecessary cross-instance traffic but adds subscription state management complexity.
At 100,000 concurrent connections, the numbers change in a qualitatively different way. Ten million dollars of compute sounds extreme, but a single large gaming or collaboration platform at 100k concurrent can easily reach that annual budget if the architecture is not efficient. The cost comparison in §6 covers this in detail.
Why the 9,400 failure mode happens specifically
The failure at 9,400 connections in the opening scenario was almost certainly not a hard limit. Managed API gateways and load balancers do not typically have hard limits in that range. What happened is more likely one of these:
The API Gateway WebSocket idle timeout kicked in. AWS API Gateway WebSocket APIs disconnect idle connections after 10 minutes, and a large deployment surge can create a situation where thousands of connections were established nearly simultaneously, meaning they also hit their idle timeout nearly simultaneously — generating a reconnect storm that overwhelms the gateway's connection acceptance rate.
Or the load balancer's maximum connection count was reached. AWS ALB has per-target connection limits that depend on instance size and configuration. Reaching this limit causes new connections to be rejected with 503, while existing connections are maintained — which explains why the count rolled back to 3,000 (the reconnect attempts were failing) and the service "recovered."
Or the application server's file descriptor limit was hit. Linux defaults to 1024 open file descriptors per process. A single WebSocket connection consumes one file descriptor. Reaching 1024 connections causes EMFILE: too many open files on new accept calls. The fix is ulimit -n 65536 or equivalent in your systemd unit file or Docker configuration — but this is the kind of limit that only manifests at scale and is invisible in staging.
Understanding which limit you hit requires instrumentation. Specifically: active WebSocket connection count per instance, open file descriptor count, Redis Pub/Sub message rate, database connection pool utilization, and load balancer target health check pass rate. These five metrics together tell you where the ceiling is before you hit it.
The pattern
The landscape of WebSocket scaling solutions falls into three broad patterns, each optimized for different constraints.
Pattern A: stateless servers with Redis pub/sub broadcast
This is the canonical pattern: application servers are stateless with respect to which room a user belongs to. Any instance can accept any connection. Fan-out happens via a shared Redis Pub/Sub bus that all instances subscribe to. The application server handles the WebSocket connection; Redis handles the cross-instance broadcast.
The architecture's strength is operational familiarity. Every engineer who has shipped a horizontally scaled service understands the model. You can add or remove application instances without coordination. You can use your existing load balancer. You can deploy Redis Cluster to shard channels across multiple Redis nodes.
The architectural weakness is fan-out amplification: every instance subscribed to a channel receives every event, even if no local connections are watching that room. At low room-count, high-connection-count scenarios (one large room with 10,000 users), this is efficient — the channel carries real traffic to every subscriber. At high room-count, low-connection-count scenarios (10,000 rooms with 10 users each), most channel events are received by instances with zero local connections for that room, wasting Redis CPU and network on events that produce no outbound frames.
The fix is the lazy subscription model described in §2: subscribe only when local connections arrive, unsubscribe when they leave. This requires tracking which channels each instance is subscribed to and managing subscription lifecycle correctly — including the edge case where the last local connection in a room disconnects exactly as a message is in-flight through the Pub/Sub layer, requiring the instance to buffer or discard the now-undeliverable event gracefully.
Redis 7.0 introduced Sharded Pub/Sub, which routes channel messages to a specific Redis Cluster shard rather than broadcasting to all shards. This is a significant improvement for high-channel-count deployments: instead of every Redis shard receiving every published message and forwarding it across the cluster, a sharded channel's messages stay on one shard. Clients subscribe to the shard that owns the channel. The result is that Redis Cluster Pub/Sub scales horizontally with the number of shards rather than creating a cross-shard broadcast amplification.
Pattern B: Cloudflare Durable Objects (room-per-instance)
Cloudflare Durable Objects invert the scaling model. Instead of stateless application servers sharing a broadcast bus, each room is a single, globally-unique Durable Object instance. All WebSocket connections for that room connect to the same DO instance, wherever it lives in Cloudflare's network. The DO itself handles fan-out: when a message arrives on one connection, the DO iterates its local connection list and calls send() on each. There is no cross-instance coordination because the coordination authority is the DO itself.
The "single-instance-per-key" property is the defining characteristic. A DO named room:abc123 is guaranteed to run as exactly one instance at any moment in the Cloudflare network. Requests to that DO — whether WebSocket upgrade requests or HTTP requests — are routed to that specific instance. Two simultaneous writers in the same room cannot create a split-brain condition because they are always writing to the same in-memory state. The consistency model is effectively serialized writes on a single-threaded JavaScript runtime.
The WebSocket Hibernation API, released in 2024, addresses the cost problem that made Durable Objects impractical for large-scale idle-connection workloads. Without hibernation, a DO with 500 open WebSocket connections is billed for the full duration those connections are open, even if nobody is sending messages. With hibernation, the DO can be evicted from memory while WebSocket clients remain connected at the network edge. When a new message arrives, the DO is rehydrated, the constructor runs again, and the message is delivered. The per-connection state (which room the connection belongs to, the user's identity) is serialized via serializeAttachment() before hibernation and deserialized on wake. The billing consequence is that DO compute charges accrue only when messages are actively processed, not during idle connection intervals — which for most collaboration tools is a significant fraction of total uptime.
The Hibernation API has a hard constraint worth noting: in-memory state is reset on each rehydration. Any state the DO holds that is not stored in the DO's durable storage or serialized via serializeAttachment() is lost across hibernation cycles. This means the fan-out logic must reconstruct its local connection list from the hibernation-attached state on each wake, not from a previously initialized Map<connectionId, WebSocket> that survived across idle periods. The DO documentation recommends minimizing constructor work for this reason.
Durable Objects can "connect thousands of clients per instance" according to the Cloudflare documentation, though no specific hard limit is published. In practice, per-room connection counts are bounded by the use case rather than a platform limit — a room with 10,000 simultaneous WebSocket connections is unusual in most collaboration applications. Where Durable Objects scale differently from Redis Pub/Sub is in room count: you can have millions of Durable Object instances (the documentation states "You can have millions of them around the world"), each handling a small number of connections, without any central coordination or subscription management overhead.
Pattern C: PartyKit
PartyKit is a platform layer over Durable Objects, acquired by Cloudflare in 2024, that abstracts the DO API into a higher-level Party.Server interface. A PartyKit server is a TypeScript class with lifecycle methods — onConnect, onMessage, onClose, onError — that maps directly to WebSocket events. PartyKit handles the DO provisioning, connection registration, and hibernation configuration.
The practical difference between writing a Durable Object directly and using PartyKit is the amount of boilerplate removed. A raw DO WebSocket server requires: the DurableObject class, the fetch() handler that detects WebSocket upgrade requests, the webSocketMessage() and webSocketClose() hibernation-compatible handlers (since the onmessage event listener pattern does not survive hibernation), and manual connection state serialization. PartyKit wraps all of this behind the Party.Server interface.
PartyKit also provides per-connection state storage (documented as 2KB maximum) and per-room key-value storage (128KB per value limit) out of the box. For many collaboration applications, this is sufficient without any additional database layer. For applications requiring complex queries or large persistent state, a separate database is still appropriate and PartyKit connects to external services normally.
The tradeoff is abstraction cost. A PartyKit deployment is tightly coupled to Cloudflare infrastructure. Moving to a different provider requires rewriting the server layer — the Party.Server interface has no equivalent on AWS or GCP. Teams that need multi-cloud portability or that already have significant investment in non-Cloudflare infrastructure should weigh this against the operational simplicity.
Pattern D: consistent hash sticky sessions (the alternative no one talks about)
A less commonly discussed approach eliminates the broadcast problem entirely by routing all connections for a room to the same application server instance via consistent hashing at the load balancer layer. HAProxy's hash-type consistent algorithm and nginx's hash directive with consistent mode both implement this. The hash key is the room ID embedded in the WebSocket upgrade request URL or header.
When implemented correctly, every connection for room abc123 lands on the same instance. Fan-out requires no Redis channel subscription — the instance directly iterates its local connection set. This eliminates the cross-instance broadcast cost and the Redis fan-out amplification entirely.
The failure mode is resharding. When an application instance is added or removed — during a rolling deploy, an auto-scale event, or a node failure — consistent hashing remaps a fraction of room assignments to different instances. Connections for remapped rooms reconnect to the new owner. This is a predictable reconnect storm proportional to the fraction of rooms that move (typically 1/n of rooms move when a new node is added to an n-node cluster). For most deployment patterns, this is manageable. For deployments where instances are frequently added and removed — aggressive auto-scaling based on CPU — consistent hash stickiness creates continuous reconnect background noise.
The other failure mode is hot spots. A room with 5,000 users is consistently hashed to one instance. That instance is overloaded while all others are idle. Consistent hashing does not solve the unequal-room-size problem. The Durable Objects and Redis Pub/Sub approaches both handle this more gracefully: Durable Objects by placing the hot room's DO on a well-resourced edge node automatically, Redis Pub/Sub by distributing the load across all instances subscribed to that channel.
The fix / approach
Instrument first: know which failure mode you have
Before changing infrastructure, instrument the four metrics that determine which ceiling you are approaching.
// Connection tracking middleware — add to your WebSocket upgrade handler import { register, Gauge, Histogram } from 'prom-client'; const activeConnections = new Gauge({ name: 'ws_active_connections_total', help: 'Number of active WebSocket connections', labelNames: ['instance_id'], }); const fanoutLatency = new Histogram({ name: 'ws_fanout_latency_seconds', help: 'Time from message publish to last local delivery', labelNames: ['room_id'], buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5], }); const connectionsByRoom = new Gauge({ name: 'ws_connections_per_room', help: 'Active WebSocket connections per room', labelNames: ['room_id'], }); // Track open file descriptor count — Linux only import { readdirSync } from 'fs'; const fdGauge = new Gauge({ name: 'process_open_fds', help: 'Number of open file descriptors', }); setInterval(() => { try { const count = readdirSync('/proc/self/fd').length; fdGauge.set(count); } catch { // Non-Linux environments } }, 5000);The critical thresholds to alert on before they become incidents:
- Active connections per instance approaching 80% of your per-process
ulimit -nsetting. Default Linux fd limit is 1024; set it to 65536 in production and alert at 52,000 connections per process. - Fan-out latency p99 exceeding 200ms under normal load. Above this threshold, the Redis Pub/Sub layer or outbound network queue is becoming the bottleneck.
- Redis Pub/Sub message rate per channel. A single channel receiving more than ~50,000 messages per second will start saturating a single-core Redis node.
- Connections-per-room distribution. A Zipf distribution (a few very large rooms and many small ones) behaves very differently from a uniform distribution. Your architecture choice should match your actual distribution.
- Active connections per instance approaching 80% of your per-process
Implementing Redis pub/sub with lazy channel subscription
The lazy subscription model is the critical optimization that separates a naive Redis Pub/Sub implementation from a production-grade one. The naive version subscribes to all room channels on startup; the production version subscribes only when a local connection for that room arrives.
import { createClient, RedisClientType } from 'redis'; import { WebSocket } from 'ws'; interface RoomSubscription { refCount: number; channelName: string; } const publisher: RedisClientType = createClient({ url: process.env.REDIS_URL }); const subscriber: RedisClientType = publisher.duplicate(); await publisher.connect(); await subscriber.connect(); // Room → set of local WebSocket connections const localRoomConnections = new Map<string, Set<WebSocket>>(); // Room → subscription metadata const roomSubscriptions = new Map<string, RoomSubscription>(); async function joinRoom(roomId: string, ws: WebSocket): Promise<void> { // Add to local connection set if (!localRoomConnections.has(roomId)) { localRoomConnections.set(roomId, new Set()); } localRoomConnections.get(roomId)!.add(ws); // Subscribe to Redis channel if this is the first local connection for this room const existing = roomSubscriptions.get(roomId); if (!existing) { const channelName = `room:${roomId}`; await subscriber.subscribe(channelName, (message: string) => { deliverLocally(roomId, message); }); roomSubscriptions.set(roomId, { refCount: 1, channelName }); } else { existing.refCount += 1; } } async function leaveRoom(roomId: string, ws: WebSocket): Promise<void> { const connections = localRoomConnections.get(roomId); if (connections) { connections.delete(ws); if (connections.size === 0) { localRoomConnections.delete(roomId); } } const sub = roomSubscriptions.get(roomId); if (sub) { sub.refCount -= 1; if (sub.refCount === 0) { // Last local connection left — unsubscribe from Redis channel await subscriber.unsubscribe(sub.channelName); roomSubscriptions.delete(roomId); } } } function deliverLocally(roomId: string, rawMessage: string): void { const connections = localRoomConnections.get(roomId); if (!connections || connections.size === 0) return; // Parse once, serialize once — avoid per-connection JSON work const payload = rawMessage; // Already serialized by publisher const startMs = Date.now(); let delivered = 0; for (const ws of connections) { if (ws.readyState !== WebSocket.OPEN) continue; // Back-pressure check: skip or queue slow clients const buffered = (ws as any)._socket?.bufferSize ?? 0; if (buffered > 32 * 1024) { // Slow client: queue high-priority events, drop ephemeral ones // (implementation in Step 3) continue; } ws.send(payload); delivered += 1; } const durationSec = (Date.now() - startMs) / 1000; fanoutLatency.observe({ room_id: roomId }, durationSec); } async function publishToRoom(roomId: string, event: object): Promise<void> { const payload = JSON.stringify(event); await publisher.publish(`room:${roomId}`, payload); }One subtle correctness issue: when the last local connection leaves a room and the
unsubscribeis in-flight, a message published to that channel by another instance will be received after the local unsubscribe completes. The Redis client processes messages asynchronously; there is no hard ordering guarantee between the unsubscribe acknowledgment and in-flight messages. Handle this by checkinglocalRoomConnections.has(roomId)insidedeliverLocallyand silently discarding messages for rooms with no local connections. This is safe because Redis Pub/Sub is at-most-once delivery — a dropped message on the local side is acceptable because the sender's instance will also deliver it.Back-pressure and slow subscriber management
Fan-out amplification is not just a CPU problem — it is a memory problem. Each undelivered frame in a slow client's send buffer sits in the Node.js process heap until the TCP stack drains it or you close the connection. At 10,000 connections with even 5% of clients being slow, unmanaged back-pressure accumulates gigabytes of buffered frames.
interface PriorityQueue { highPriority: string[]; // State-bearing events: message, presence snapshot lowPriority: string[]; // Ephemeral events: typing indicators, cursor moves } const clientQueues = new WeakMap<WebSocket, PriorityQueue>(); const MAX_HIGH_PRIORITY_QUEUE = 200; const BUFFER_THRESHOLD_BYTES = 32 * 1024; // 32KB function sendOrQueue( ws: WebSocket, payload: string, isHighPriority: boolean, ): void { const buffered = (ws as any)._socket?.bufferSize ?? 0; if (buffered <= BUFFER_THRESHOLD_BYTES) { // Fast path: send immediately ws.send(payload); return; } if (!isHighPriority) { // Ephemeral event: drop silently under back-pressure return; } // High-priority: queue with bounded depth let queue = clientQueues.get(ws); if (!queue) { queue = { highPriority: [], lowPriority: [] }; clientQueues.set(ws, queue); } if (queue.highPriority.length >= MAX_HIGH_PRIORITY_QUEUE) { // Queue full: client is too slow to recover gracefully // Close and let it reconnect + replay from event log ws.close(1001, 'Send queue overflow — reconnect to resync'); return; } queue.highPriority.push(payload); } // Drain queues on each tick when buffer has space function drainClientQueues(): void { // This runs on a setImmediate loop, not a setInterval — // fires after each I/O tick, not on a fixed timer for (const [ws, queue] of clientQueues.entries() as any) { if (ws.readyState !== WebSocket.OPEN) { clientQueues.delete(ws); continue; } const buffered = (ws as any)._socket?.bufferSize ?? 0; if (buffered > BUFFER_THRESHOLD_BYTES) continue; // Drain high-priority queue first while ( queue.highPriority.length > 0 && (ws as any)._socket?.bufferSize <= BUFFER_THRESHOLD_BYTES ) { ws.send(queue.highPriority.shift()!); } } setImmediate(drainClientQueues); } setImmediate(drainClientQueues);The 32KB threshold is a practical starting point. For rooms where events are large (embedded document snapshots, binary frames), raise it proportionally. For rooms where events are small (cursor positions, short chat messages), 16KB is adequate. The goal is to detect a stalled send buffer before it grows to multiple megabytes per connection.
Closing a slow client with close code 1001 (Going Away) is the correct signal to a well-implemented client: reconnect and replay from the last-acknowledged event ID. This is only safe if your client implements idempotent replay — receiving events you have already processed should be a no-op, not a duplicate. If your client does not implement replay idempotency, closing slow clients will produce visible duplicate events in the UI.
Durable Objects room server implementation
The Durable Objects implementation removes the Redis layer entirely. Each room is a DO instance; fan-out is a local in-memory iteration. The key difference from a stateless server is that you must use the Hibernation API's event handlers (
webSocketMessage,webSocketClose,webSocketError) instead of the standardaddEventListenerpattern — the latter does not survive hibernation.// room-server.ts — Cloudflare Durable Object with Hibernation API import { DurableObject } from 'cloudflare:workers'; interface ConnectionMeta { userId: string; displayName: string; joinedAt: number; } export class RoomServer extends DurableObject { // In-memory connection map — rebuilt from serializeAttachment on each wake private connections = new Map<WebSocket, ConnectionMeta>(); async fetch(request: Request): Promise<Response> { const upgradeHeader = request.headers.get('Upgrade'); if (!upgradeHeader || upgradeHeader.toLowerCase() !== 'websocket') { return new Response('Expected WebSocket upgrade', { status: 426 }); } // Authenticate before upgrading — reject unauthorized connections at the gate const userId = request.headers.get('X-User-Id'); const displayName = request.headers.get('X-Display-Name') ?? 'Anonymous'; if (!userId) { return new Response('Unauthorized', { status: 401 }); } // Use Hibernation API: acceptWebSocket instead of new WebSocketPair() const { 0: clientWs, 1: serverWs } = new WebSocketPair(); const meta: ConnectionMeta = { userId, displayName, joinedAt: Date.now(), }; // Accept with hibernation enabled this.ctx.acceptWebSocket(serverWs); // Serialize connection metadata so it survives hibernation serverWs.serializeAttachment(meta); // Register in local connection map (ephemeral — rebuilt on wake) this.connections.set(serverWs, meta); // Broadcast join event to existing connections this.broadcast( { type: 'presence.joined', userId, displayName, timestamp: Date.now(), }, serverWs, ); // Send current presence list to the joining user const presence = this.getCurrentPresence(); serverWs.send(JSON.stringify({ type: 'presence.snapshot', users: presence })); return new Response(null, { status: 101, webSocket: clientWs }); } // Hibernation API handler — called for each incoming WebSocket message async webSocketMessage( ws: WebSocket, message: string | ArrayBuffer, ): Promise<void> { // Re-hydrate connection meta from serialized attachment const meta = ws.deserializeAttachment() as ConnectionMeta; // Ensure local connection map is populated (may have been lost on hibernate/wake) if (!this.connections.has(ws)) { this.connections.set(ws, meta); } let parsed: unknown; try { parsed = JSON.parse(typeof message === 'string' ? message : new TextDecoder().decode(message)); } catch { ws.close(1007, 'Invalid JSON'); return; } await this.handleMessage(ws, meta, parsed as Record<string, unknown>); } async webSocketClose( ws: WebSocket, code: number, reason: string, ): Promise<void> { const meta = ws.deserializeAttachment() as ConnectionMeta | null; this.connections.delete(ws); if (meta) { this.broadcast({ type: 'presence.left', userId: meta.userId, timestamp: Date.now(), }); } } async webSocketError(ws: WebSocket, error: unknown): Promise<void> { this.connections.delete(ws); ws.close(1011, 'Internal error'); } private async handleMessage( ws: WebSocket, meta: ConnectionMeta, msg: Record<string, unknown>, ): Promise<void> { switch (msg.type) { case 'message.send': { const text = typeof msg.text === 'string' ? msg.text.slice(0, 4096) : ''; if (!text) return; // Persist to Durable Object storage (SQLite-backed) const msgId = crypto.randomUUID(); await this.ctx.storage.put(`msg:${Date.now()}:${msgId}`, { id: msgId, userId: meta.userId, displayName: meta.displayName, text, timestamp: Date.now(), }); // Fan out to all connections in this DO instance (no Redis needed) this.broadcast({ type: 'message.received', id: msgId, userId: meta.userId, displayName: meta.displayName, text, timestamp: Date.now(), }); break; } case 'presence.heartbeat': { // Update presence timestamp — ephemeral, no storage needed this.broadcast( { type: 'presence.heartbeat', userId: meta.userId, timestamp: Date.now(), }, ws, // Exclude sender ); break; } } } private broadcast(event: object, exclude?: WebSocket): void { const payload = JSON.stringify(event); const activeSockets = this.ctx.getWebSockets(); for (const sock of activeSockets) { if (sock === exclude) continue; if (sock.readyState === WebSocket.OPEN) { sock.send(payload); } } } private getCurrentPresence(): Array<{ userId: string; displayName: string }> { const sockets = this.ctx.getWebSockets(); const seen = new Set<string>(); const result: Array<{ userId: string; displayName: string }> = []; for (const sock of sockets) { const meta = sock.deserializeAttachment() as ConnectionMeta | null; if (meta && !seen.has(meta.userId)) { seen.add(meta.userId); result.push({ userId: meta.userId, displayName: meta.displayName }); } } return result; } }The
ctx.getWebSockets()method is the correct way to enumerate connections in a hibernation-compatible DO. It returns all WebSocket connections the DO has accepted, regardless of whether the DO has been rehydrated since they connected. This is the mechanism that makes fan-out work correctly across hibernation cycles — you do not need to maintain your ownSet<WebSocket>that would be lost on hibernate, becausegetWebSockets()always returns the current live connections.Connection draining on rolling deploy
Rolling deploys are the most predictable source of connection storms. When an instance is replaced, all connections on it must migrate to remaining instances. If every client reconnects simultaneously within 500ms, the remaining instances experience a sudden load spike.
The solution is a graceful drain period. When a shutdown signal (SIGTERM) is received, the instance stops accepting new connections and notifies existing clients to reconnect. The notification includes a
reconnectIndelay that is randomized per-client to spread the reconnect ramp.// Graceful drain on SIGTERM let isDraining = false; process.on('SIGTERM', async () => { isDraining = true; // Stop accepting new WebSocket upgrades // (handled by checking isDraining in the upgrade handler) // Notify all connected clients to reconnect with randomized delay const allConnections = getAllActiveConnections(); // your connection registry for (const [ws, meta] of allConnections) { if (ws.readyState !== WebSocket.OPEN) continue; // Each client gets a random delay between 1s and 30s // This spreads 10,000 reconnects over a 30-second window const reconnectInMs = Math.floor(Math.random() * 29000) + 1000; ws.send(JSON.stringify({ type: 'server.draining', reconnectInMs, reason: 'Server is restarting for deployment', })); // Close after a grace period to let the client process the message setTimeout(() => { if (ws.readyState === WebSocket.OPEN) { ws.close(1001, 'Server draining'); } }, reconnectInMs + 2000); } // Wait for connections to drain before process exits const maxDrainMs = 35_000; // 35s max drain time const start = Date.now(); while (getAllActiveConnections().size > 0 && Date.now() - start < maxDrainMs) { await new Promise((resolve) => setTimeout(resolve, 1000)); } process.exit(0); }); // Client-side reconnect handler function handleServerDraining(msg: { reconnectInMs: number }): void { setTimeout(() => { // Jitter already applied by server — reconnect immediately on receipt of signal reconnect(); // Your existing reconnect function with exponential backoff }, msg.reconnectInMs); }The server-side randomization distributes the reconnect ramp without requiring the client to implement independent jitter logic. This is useful when you do not control the client codebase (e.g., SDK clients distributed as npm packages where updating the reconnect behavior requires a version bump and client upgrades).
For Durable Objects and PartyKit, rolling deploy behavior is different: code updates disconnect all WebSockets immediately, and there is no drain period available by default. The Cloudflare Workers documentation notes this explicitly for DO code updates. The client-side jittered backoff is therefore even more important in the DO case, as the client is the only layer that can smooth the reconnect ramp.
Presence at scale: the awareness protocol
Presence state — who is in the room, cursor positions, typing indicators — is the most write-amplified signal in a collaborative system. A room with 200 users where each user sends a presence heartbeat every 5 seconds generates 40 presence events per second. If each event is broadcast to all 200 connections, that is 8,000 outbound frames per second from presence alone, before any actual messages.
The Yjs awareness protocol addresses this through a gossip-style propagation model: each client maintains its own awareness state as a map of
clientId → state. When a client's state changes, it broadcasts only its own state delta, not the full room state. Other clients merge the incoming delta into their local awareness map. The merge operation is a simple key-based last-write-wins on the per-client state: since each client only writes to its own key, there are no merge conflicts.The awareness protocol's TTL mechanism handles disconnection without explicit leave events: each awareness state entry includes a clock value. If a client's clock is not refreshed within the configured TTL (default 30 seconds in y-websocket), other clients expire that entry from their local awareness map. Disconnects without a clean close (network drop, browser crash, mobile network switch) are handled automatically without server-side presence sweep logic.
For the Redis Pub/Sub architecture, the awareness state lives in a Redis Hash keyed by room ID. Each field is a user ID, and the value is the serialized awareness state. Heartbeats update the field and reset a sorted-set entry used for TTL expiry:
const AWARENESS_TTL_SECONDS = 30; async function updateAwareness( roomId: string, userId: string, state: Record<string, unknown>, ): Promise<void> { const pipeline = publisher.multi(); // Store awareness state pipeline.hSet(`awareness:${roomId}`, userId, JSON.stringify(state)); // Update TTL tracking in sorted set (score = expiry timestamp) const expiresAt = Math.floor(Date.now() / 1000) + AWARENESS_TTL_SECONDS; pipeline.zAdd(`awareness-ttl:${roomId}`, { score: expiresAt, value: userId }); // Expire the entire awareness hash if no activity for 5× TTL pipeline.expire(`awareness:${roomId}`, AWARENESS_TTL_SECONDS * 5); pipeline.expire(`awareness-ttl:${roomId}`, AWARENESS_TTL_SECONDS * 5); await pipeline.exec(); // Broadcast only the delta, not the full room state await publishToRoom(roomId, { type: 'awareness.update', userId, state, expiresAt, }); } // Background sweep — runs on one instance only (use Redis lock or cron job) async function sweepExpiredAwareness(roomId: string): Promise<void> { const now = Math.floor(Date.now() / 1000); const pipeline = publisher.multi(); // Find expired entries const expired = await publisher.zRangeByScore(`awareness-ttl:${roomId}`, '-inf', now); if (expired.length > 0) { pipeline.hDel(`awareness:${roomId}`, expired); pipeline.zRemRangeByScore(`awareness-ttl:${roomId}`, '-inf', now); await pipeline.exec(); // Notify room of expired presences await publishToRoom(roomId, { type: 'awareness.expired', userIds: expired, timestamp: now, }); } }For the Durable Objects architecture, awareness sweeping is handled by a DO alarm. The DO sets an alarm at
Date.now() + 30_000when a connection joins and resets it on each heartbeat. When the alarm fires, the DO checks which connections have stale heartbeats and emits presence-left events. This eliminates the external sweep process and keeps presence logic fully inside the DO.
Anti-patterns
Fan-out via HTTP callbacks instead of persistent connection
What it looks like: When a message is published, the application server makes an HTTP POST to every other application server instance's internal API endpoint. Each instance fans out to its local connections. There is no Redis dependency.
Why it fails: At 10 instances and 100 messages per second, this is 1,000 HTTP requests per second of internal traffic. HTTP is not optimized for high-frequency small-payload messaging: TLS handshake overhead, HTTP header overhead, and connection pool management all add latency that does not exist in a Redis Pub/Sub subscriber connection. Under load, the internal HTTP request queue backs up, introducing seconds of fan-out delay. More critically, a slow or crashed instance causes the fanout to accumulate failed HTTP requests, triggering retries that amplify the problem. Redis Pub/Sub's fire-and-forget delivery model sidesteps this entirely.
Do instead: Use Redis Pub/Sub or a dedicated message broker (Redis Streams, NATS, or similar). The subscriber connection is long-lived and low-overhead. Delivery is near-zero-latency for payloads that fit in Redis's memory.
One Durable Object per user instead of per room
What it looks like: Each user has a personal Durable Object that represents their connection state. When a message is published to a room, the sender's DO sends HTTP requests to each room member's personal DO.
Why it fails: This is the HTTP callback anti-pattern in a Cloudflare-native costume. A room with 200 users generates 200 DO-to-DO HTTP subrequests for every single message. Each subrequest is billed, introduces latency, and creates failure surface. At 10 messages per second in that room, you are making 2,000 subrequests per second. The DO per-room model exists precisely to eliminate this fan-out via HTTP: all room connections live on one DO instance, fan-out is a local for loop, and there are zero subrequests.
Do instead: Model rooms, not users, as Durable Objects. One DO per room. All users in the room connect to the same DO. Fan-out is in-process.
Presence heartbeat rate tied to animation frame rate
What it looks like: Cursor position updates are sent on every requestAnimationFrame callback — approximately 60 times per second — so that cursor movement appears smooth to other users.
Why it fails: At 60 Hz × 200 users in a room, the server receives 12,000 presence events per second for that room alone. Each event is a Redis Pub/Sub publish and a fan-out to 200 connections. The network and Redis costs are disproportionate to the user-experience gain. Cursor movement at 60 Hz between server and client is also fundamentally different from cursor movement at 60 Hz on a local screen: the network round-trip introduces 20–100ms of latency regardless of how fast you send updates, making the 60 Hz data rate meaningless for smoothness.
Do instead: Apply motion interpolation on the client (Lerp or similar) and send cursor positions at a rate commensurate with the network round-trip. 10–15 Hz (every 66–100ms) is sufficient for smooth perceived cursor movement under typical network conditions. Use velocity-based throttling: send more frequently when the cursor is moving fast, less frequently when it is stationary. This achieves perceptual smoothness at 1/4 of the event rate.
Broadcast everything to everyone in a room
What it looks like: Every event — messages, presence, typing indicators, cursor positions, read receipts — is published to a single room-level Redis channel and delivered to every connection in the room.
Why it fails: Different event types have radically different delivery requirements. A read receipt for user A reading message B is relevant only to user A (for their own UI state) and possibly to the message author (for a read indicator). Broadcasting it to all 200 room participants means 199 out of 200 recipients receive an event they ignore and discard. This is wasted bandwidth, CPU (JSON parse on the client), and Redis fan-out.
Do instead: Implement topic-level subscriptions within a room. Each connection subscribes to a base room channel for room-wide events (new messages, join/leave presence) and to personal channels for user-specific events (read receipts, direct replies, notification badges). The Socket.io room-and-namespace model implements this pattern. The Durable Objects implementation handles it via a routing table inside the DO: incoming events with a targetUserId field are sent only to connections matching that user ID.
// Selective delivery in a Durable Object
private deliverToUser(userId: string, event: object): void {
const payload = JSON.stringify(event);
for (const sock of this.ctx.getWebSockets()) {
const meta = sock.deserializeAttachment() as ConnectionMeta | null;
if (meta?.userId === userId && sock.readyState === WebSocket.OPEN) {
sock.send(payload);
}
}
}Treating WebSocket disconnects as state loss
What it looks like: When a client disconnects, the server immediately purges all state associated with that client — queued messages, draft content, pending writes. On reconnect, the client starts from scratch.
Why it fails: Mobile clients on cellular networks disconnect and reconnect dozens of times per hour. A user typing a long message in a browser tab that goes backgrounded may have their connection torn down by the browser's connection throttling for inactive tabs. Treating these disconnects as permanent causes visible state loss (draft messages disappearing, recent messages not appearing after reconnect) that users report as "the app keeps losing my work."
Do instead: Retain client state for a reconnect window (5–15 minutes is typical). On the server: preserve pending events in a per-connection queue for the duration of the reconnect window; replay them when the client reconnects with its last-seen event ID. On the client: persist draft content to localStorage before sending and clear it only on successful server acknowledgment. Distinguish deliberate disconnects (tab close, explicit sign-out) from network disconnects (no close frame, TCP reset) and handle them differently.
Y-Sweet as a drop-in replacement for Redis pub/sub
What it looks like: An engineering team reads about Y-Sweet (a Yjs backend built on Cloudflare Durable Objects) and deploys it as the primary WebSocket layer for a non-Yjs application.
Why it fails: Y-Sweet is purpose-built for Yjs CRDT document synchronization. It provides a Yjs-over-WebSocket server backed by Durable Objects and handles document persistence, awareness, and sync protocol. It is not a general-purpose WebSocket message broker. Attempting to layer non-Yjs protocols over it requires working around the Yjs message framing, which adds complexity without the CRDT benefits. If you are not using Yjs shared types for your application state, Y-Sweet is not the right layer — use Durable Objects directly or PartyKit.
Case studies / numbers
The following are composite observations from building and operating WebSocket-backed services across multiple deployments. Numbers are scoped to the specific contexts in which they were observed and are not presented as general industry benchmarks.
Composite A: the 9,400-connection API Gateway wall
An engineering team running a collaborative web application on AWS API Gateway WebSocket APIs hit a systematic ceiling at approximately 9,000–10,000 concurrent connections despite nominal quota limits well above that. Root cause analysis revealed two overlapping constraints.
First, the API Gateway WebSocket idle timeout is 10 minutes, with a maximum connection duration of 2 hours. During a usage spike, the team had approximately 8,000 connections that were approaching the 10-minute idle threshold simultaneously (they had all been established during the same 30-minute surge window). When those 8,000 connections were terminated by the gateway, the immediate reconnect attempt — with no client-side backoff — generated 8,000 simultaneous new connection requests. The Lambda function handling $connect route cold-started under this burst, adding 800ms–2s of connection establishment latency per request. The backpressure from slow Lambda responses caused the API Gateway to queue and then reject incoming connection requests, returning 503s to clients who had not yet hit their idle timeout.
Second, the team's Lambda integration for message routing was synchronous — the Lambda had to return before API Gateway would deliver the next message on that connection. Under load, the Lambda's 29-second maximum integration timeout meant connections that arrived during the cold-start window were experiencing multi-second delivery latencies for messages that should have been sub-100ms.
The resolution involved three changes: migrating from API Gateway WebSocket to an ALB-fronted self-hosted WebSocket server (eliminating the Lambda-per-message cost and the idle timeout), implementing client-side jittered reconnect with a 1s–30s range (spreading the reconnect ramp from a 500ms cluster to a 30-second ramp), and implementing connection draining on the server side to emit randomized reconnect delays before server shutdowns. Post-migration, the service held 25,000 concurrent connections on 6 application server instances with p99 delivery latency under 50ms.
Composite B: Durable Objects at room scale, not user scale
A team building a whiteboard collaboration tool initially modeled users as Durable Objects — each user's pointer state, selection, and draft canvas state lived in a personal DO instance. When two users were in the same room, their DOs communicated via HTTP subrequests to exchange state updates.
At 20 users in a room, this generated 380 subrequests per second (each of the 20 users broadcasting 19 updates per second to the 19 other users). The DO-to-DO HTTP subrequest latency added 30–80ms per hop, making the whiteboard feel unresponsive during active collaboration sessions. The Cloudflare Workers billing for subrequests added up to roughly 3× the expected cost at that user count.
The team migrated to a room-as-DO model. All 20 users' WebSocket connections attach to a single room DO. Fan-out is a local for loop with no subrequests. The observed fan-out latency dropped from 30–80ms (subrequest round-trip) to under 1ms (in-process iteration). Cloudflare billing for the whiteboard feature dropped by approximately 65% at equivalent user counts, primarily from the elimination of subrequest billing. The room DO's WebSocket Hibernation API meant that rooms with no active sessions cost nothing while idle, making the per-room model economically viable even at thousands of rooms with irregular usage patterns.
Composite C: cost comparison at 100k concurrent connections
Rough cost estimates across the three architectures at 100,000 concurrent WebSocket connections with 5 messages/second/room, 50 rooms, 2,000 users per room. These are approximations based on published pricing at time of writing — actual costs depend on region, data transfer, instance sizing, and message volume.
Redis Pub/Sub + stateless app servers on AWS:
- EC2 instances: 20× c6i.2xlarge ($0.34/hr each) = $6.80/hr = ~$4,900/month
- ElastiCache (Redis): r6g.2xlarge primary + 2 replicas = ~$1,200/month
- ALB: ~$0.008/LCU-hour; at 100k connections estimate ~50 LCU = $3/day = ~$90/month
- Approximate total: ~$6,200/month
Cloudflare Durable Objects:
- DO compute billed at $12.50/million GB-seconds. At 100k connections with hibernation, active compute time depends heavily on message rate. At 5 msg/sec/room × 50 rooms = 250 msg/sec total, each taking ~1ms of DO CPU, approximate compute: 250 × 0.001s × 128MB = ~0.032 GB-s/sec = ~2,765 GB-s/day = ~83,000 GB-s/month. At $12.50/million: ~$1.04/month for DO compute.
- DO storage: negligible for message rooms with short retention.
- Workers Paid plan: $5/month base + outbound data transfer. At 50MB/s aggregate outbound: ~130TB/month at $0.09/GB = ~$11,700/month for egress.
- Approximate total: ~$11,700/month (dominated by Cloudflare egress pricing at this scale)
Managed service (Liveblocks, Ably, or equivalent):
- Pricing typically per connection-hour plus per-message. At 100k concurrent connections × 730 hours/month + 250 msg/sec × 2.6M seconds/month: varies widely by provider but typically $15,000–$40,000/month at this scale.
- Approximate total: $15,000–$40,000/month (operational simplicity included)
The cost comparison illustrates a pattern that is easy to miss: Durable Objects compute is very cheap (hibernation means you pay only for active message processing), but Cloudflare egress pricing makes high-volume broadcast scenarios more expensive than EC2-based alternatives where data stays within the VPC. For presence-heavy, message-light scenarios (many connections, few messages per second), Durable Objects economics are strongly favorable. For high-frequency broadcast scenarios (large rooms, high message rates), Redis Pub/Sub on EC2 is typically cheaper at equivalent scale.
Composite D: the connection storm on rolling deploy
A team operating 12 application server instances with 8,000 total WebSocket connections deployed a hotfix during business hours. Their deployment pipeline replaced instances one at a time with a 2-minute interval between replacements. Each replacement triggered 667 connections to reconnect (8,000 ÷ 12 instances). Without client-side jitter, all 667 reconnects occurred within 300ms of the TCP RST propagating.
The Postgres connection pool was sized for normal load. Each reconnection attempt queried the authentication database to validate the session token. 667 simultaneous authentication queries saturated the pool (which had 50 connections) and caused a 4-second queue to form. Users on the reconnecting instances experienced a 4–8 second black-screen period where the application appeared unresponsive. Because the deploy was rolling (one instance at a time), this repeated 12 times over 24 minutes — a significant user experience degradation for the duration of the deploy.
The fix was two-pronged: client-side jittered reconnect (1s base, ±3s jitter, exponential to 30s cap) and server-side drain notification (each instance sent a server.draining message with randomized per-client reconnect delays before shutdown). After these changes, the reconnect ramp for each instance replacement spread over approximately 15–20 seconds, reducing peak authentication query rate by 80% and eliminating the visible blackout period. Total deploy time increased by 3 minutes (due to the drain period) but user-visible disruption dropped to zero for 94% of users across subsequent deployments.
Limits + caveats
Durable Objects are not suitable for cross-room coordination
A Durable Object instance is isolated. It can communicate with other DOs via HTTP subrequests, but there is no native shared memory or pub/sub primitive between DO instances. If your application needs to aggregate state across rooms — for example, a server-wide user count, a global rate limiting counter, or a cross-room notification broadcast — you need either a dedicated coordination DO (one DO that receives subrequests from all room DOs and maintains global state) or an external store like a Workers KV namespace or D1 database.
Workers KV has an eventual consistency model with up to 60 seconds of lag in the global distribution, making it unsuitable for real-time state that must be immediately consistent. Durable Objects themselves are the recommended primitive for globally consistent counters and coordination on Cloudflare, but the subrequest overhead adds up at scale. Design your room model to minimize cross-room coordination needs; if your application inherently requires it, the DO architecture requires careful design to avoid subrequest fan-out becoming the bottleneck.
Redis Pub/Sub at-most-once delivery means event replay is mandatory
Redis Pub/Sub delivers messages at most once. A subscriber that is briefly disconnected from Redis (network hiccup, Redis failover, Redis restart) will miss messages published during the gap. In a comment or collaboration system, missed messages are invisible to the user: the content simply does not appear. Without event replay on reconnect, your system silently loses data under failure conditions that are invisible in testing but common in production.
Every Redis Pub/Sub WebSocket system must pair the broadcast layer with a persistent event log and a replay endpoint. This is not optional complexity — it is the fundamental mechanism that makes at-most-once fan-out safe for user-facing content. The event log replay pattern is covered in the prior post in this series; the specific interaction with Redis reconnection is worth highlighting: when a Redis subscriber reconnects, it misses all messages published during the gap. The application server must detect the Redis reconnect event (via the reconnecting event on the redis client) and trigger a replay of missed events from the persistent store for all locally connected WebSocket clients.
AWS API Gateway WebSocket has a 2-hour maximum connection lifetime
This is a hard limit documented in the AWS API Gateway service (the 1001 close code description explicitly states "API Gateway returns this status code when the client is idle for 10 minutes or reaches the maximum 2 hour connection lifetime"). Applications that expect users to maintain a single WebSocket connection for longer than 2 hours — desktop applications, long-running sessions — must implement proactive reconnection before the 2-hour expiry. A client-side timer that reconnects at 115 minutes (5 minutes before the hard limit) handles this cleanly.
AWS NLB does not have this 2-hour limit and has no idle connection timeout (unlike ALB, which has a configurable idle timeout defaulting to 60 seconds). For WebSocket services that need long-lived connections without managed timeout complexity, NLB fronting a self-hosted WebSocket server is the AWS-native alternative to API Gateway WebSocket.
PartyKit code updates drop all connections
Unlike AWS ALB deployments where you can drain connections gracefully before taking an instance out of service, a PartyKit (and Durable Objects generally) code update disconnects all WebSocket clients connected to any DO whose code changes. This is documented behavior — Cloudflare cannot know whether new code is backward-compatible with existing in-flight connections. The client-side reconnect behavior is therefore more critical in PartyKit deployments than in self-hosted deployments: every code deploy is a full reconnect event for all active users. Plan your deploy cadence accordingly and invest in client-side reconnect quality before deploying frequently.
Presence at 10,000 users per room is a qualitatively different problem
Everything described in this post about presence — awareness heartbeats, TTL-based expiry, sorted-set sweep — is designed for rooms with tens to hundreds of concurrent users. At 10,000 users in a single room, the presence fan-out for a single join or leave event is 10,000 deliveries. At even 1 join/leave event per second (which is low for a 10,000-person room), presence changes alone generate 10,000 frames per second from that one room.
At this scale, presence is typically decoupled from per-user state: instead of broadcasting each individual's join/leave event, the server broadcasts an aggregate count ("3,142 viewers online") and provides detailed presence only for a subset (e.g., users who have recently interacted, up to 50 shown in the UI). The Liveblocks approach of providing detailed presence only for users who are actively in the same focused area of a document is one solution. The Yjs awareness protocol's CRDT-based approach is another — it gossips state deltas rather than broadcasting full presence lists, keeping the per-event payload bounded regardless of room size. Building this yourself for a 10,000-user room is a significant engineering investment; for most applications, restricting the detailed presence display to 50–100 users and showing an aggregate count for the rest is the pragmatic path.
Sources & further reading
-
Cloudflare Durable Objects documentation — Official documentation covering the single-instance-per-key model, storage API, and the WebSocket use case. The "Connect through Durable Objects" guide is the entry point for WebSocket room implementations.
-
Cloudflare Workers WebSocket Hibernation API — Documents the
acceptWebSocket,getWebSockets,serializeAttachment, anddeserializeAttachmentmethods that enable DO hibernation while WebSocket connections remain open. Covers the behavior of in-memory state reset across hibernate/wake cycles. -
AWS API Gateway WebSocket API overview — Official documentation for API Gateway WebSocket APIs, including the explicit documentation of the 10-minute idle timeout and 2-hour maximum connection lifetime (close code 1001). The
$connectand$disconnectroute lifecycle is described here. -
Redis Pub/Sub documentation — Canonical reference for Redis Pub/Sub semantics: at-most-once delivery, the subscriber-mode connection constraint (cannot issue other commands), pattern subscriptions via PSUBSCRIBE, and the RESET command for exiting subscriber mode.
-
PartyKit documentation — PartyServer API — Reference for the
Party.Serverinterface including theonConnect,onMessage,onClose,onErrorlifecycle, per-connection state storage (2KB limit), and room key-value storage (128KB per value limit). The hibernation behavior and its implications for state management are described here. -
Yjs documentation — y-websocket provider — Documents the y-websocket connection provider including the awareness protocol, reconnection handling via
shouldConnect, and thesyncedevent for detecting initial synchronization completion. Covers persistence via LevelDB and pub/sub-based scaling for multiple y-websocket server instances. -
Socket.IO documentation — using multiple nodes — Documents the Socket.IO adapter pattern for horizontal scaling: Redis adapter, sticky session configuration (cookie-based via HAProxy/Traefik, IP-hash via nginx), and the tradeoffs between adapter-based and sticky-session approaches.
-
AWS API Gateway pricing — WebSocket APIs — Pricing reference for API Gateway WebSocket: $0.25 per million connection-minutes, $1.00 per million messages (in 32KB increments). At 100k connections × 43,800 minutes/month, the connection-minute cost alone is approximately $1,095/month before any message billing.
-
Cloudflare Durable Objects best practices — WebSockets — Covers the specific recommendations for message batching ("fewer, larger messages"), minimizing constructor work for hibernation-compatible DOs, and the
serializeAttachment/deserializeAttachmentpattern for per-connection state that survives hibernation. -
Redis 7.0 release notes — Sharded Pub/Sub — The Pub/Sub documentation covers sharded pub/sub (SSUBSCRIBE/SPUBLISH), which routes channel messages to a specific cluster shard instead of broadcasting across all shards. Critical for high-channel-count Redis Cluster deployments.
- Tech
Server-Sent Events vs WebSockets in 2026: when SSE wins, backpressure, reconnect semantics
A streaming LLM endpoint that worked on Chrome failed silently on Safari at 30 KB/s. The SSE vs WebSocket decision and the backpressure, reconnect, and heartbeat patterns that survive production.
- Tech
Webhook signature verification done right: HMAC, timing-safe compare, and replay defense
A webhook endpoint validated the signature with `===` and got owned by a side-channel attack three months later. The HMAC, timing-safe, and replay-window patterns that actually work.
- Tech
Postgres connection pooling at scale + serverless cold starts: pgbouncer, Supavisor, RDS Proxy
A serverless API that opened 4,200 idle Postgres connections during a Black-Friday burst. The pool-mode, prepared-statement, and transaction-scope choices that survive serverless.