The WebSocket decision record: why the connection state model you chose determines your horizontal scaling ceiling and your deployment disruption surface
WebSocket decisions are made during three sessions that never document the consequences — the connection model session that chooses in-memory room state without specifying the sticky session dependency, the broadcast model session that pushes events to all clients without calculating O(n) fan-out at projected connection counts, and the deployment session that configures rolling updates without accounting for connection disruption. What none of these sessions produce is the connection state externalization contract, the fan-out architecture model, or the reconnection protocol specification.
A 15-person startup building a project management tool added real-time collaborative document editing to their product. An engineer evaluated Socket.io and chose it for its abstraction over the WebSocket protocol and its built-in room concept — clients could join a room by document ID and emit events that all other clients in the room would receive. The engineer built the feature over two weeks: the Node.js server maintained in-memory room state (which user IDs were in which room, each user's current cursor position, the pending operation queue for operational transformation), and the frontend Socket.io client reconnected automatically when connections dropped. The feature launched successfully and was well-received by users who valued seeing collaborators' cursors in real time.
The production deployment was a single Node.js process behind an AWS Application Load Balancer, which distributed WebSocket connections across however many instances were running. For the first three months, this was a single EC2 instance. When traffic grew and the team needed to scale to three instances for reliability, the load balancer began distributing new connections across all three. Within an hour of the scale-out, users in collaborative editing sessions began reporting that they could see their own cursor but not their collaborators' — the team member's cursor had disappeared. The session that had diagnosed the problem: when a new connection from User A arrived at instance 1 and User A's collaborator User B was connected to instance 2, they were in different instances' in-memory rooms. Events emitted by User A to their local room on instance 1 never reached User B on instance 2.
The team added sticky sessions to the load balancer (the ALB's session stickiness feature, which issues a cookie and routes subsequent requests from that client to the same target). Sticky sessions resolved the in-room visibility problem — all collaborators in the same document ended up routed to the same instance. Three weeks later, the team needed to deploy a security patch to the Node.js process. The standard deployment procedure was to terminate the old Auto Scaling Group instances and bring up new ones. When the old instances were terminated, all WebSocket connections to those instances were immediately dropped. Socket.io's automatic reconnection logic reconnected clients to the new instances — but the new instances had empty in-memory state. Every collaborative editing session lost its room membership, cursor state, and pending operation queue simultaneously. Users who were mid-document saw all collaborators disappear. Users who were actively typing lost their pending operations that had not yet been applied. The reconnection storm (all clients reconnecting within 30 seconds) briefly overloaded the new instances' connection acceptance rate.
The team spent the following week integrating the Socket.io Redis adapter, which stores room membership in Redis and uses Redis pub/sub to deliver events from any instance to all clients in a room regardless of which instance they are connected to. The adapter resolved both the multi-instance visibility problem and the deploy-time state loss — with room membership in Redis, a client that reconnects after a deploy finds its room still populated in Redis and rejoins correctly. The two-day integration would have been the initial design if the founding session had documented that the in-memory room model creates a constraint: the server cannot scale horizontally without sticky sessions, and sticky sessions are insufficient to handle instance replacement because the session cookie routes to an instance that no longer exists after a deploy. The founding session recorded "Socket.io with room-based routing" without recording the state externalization requirement that rooms must be stored in a shared store before horizontal scaling or rolling deploys are attempted.
An 18-person e-commerce startup built a real-time operations dashboard for their supply chain partners. The dashboard displayed a live stream of order events — new orders placed, shipments dispatched, inventory adjustments — so that warehouse managers and logistics coordinators at partner organizations could see the state of the supply chain as it evolved throughout the day. The WebSocket server was a Node.js process that consumed order events from a Kafka topic and broadcast each event to all connected clients. At launch, the number of connected partners was small — typically between 30 and 80 concurrent connections during business hours. At 80 connections, the broadcast loop completed in under 2ms per event, well within the team's informal goal of keeping the dashboard "near real time."
The dashboard became a critical operational tool. Partners began leaving it open full-time, connected from multiple browser tabs. A feature allowing individual warehouse workers to connect — not just managers — increased the per-partner connection count. Seven months after launch, the average concurrent connection count during business hours had grown from 60 to 620. The broadcast latency was still acceptable at 620 connections — around 18ms per event — though nobody had measured it against any SLA.
The startup launched its first major promotional campaign during Black Friday, offering significant discounts to consumers and bonus incentives to supply chain partners who fulfilled orders above target thresholds. The marketing team expected a 4x traffic increase. What they had not accounted for was that the promotional incentive caused every supply chain partner to open the dashboard simultaneously and keep it open all day to track their fulfillment progress against the bonus threshold. By 9:00 AM on Black Friday, the concurrent WebSocket connection count had reached 2,400 — nearly four times the previous peak.
The Kafka consumer continued receiving order events at an elevated rate. But the broadcast loop was no longer completing quickly. Each broadcast iterated over 2,400 socket references sequentially in Node.js's single-threaded event loop. A single broadcast at 2,400 connections took approximately 340ms to complete — the loop had to serialize each event payload, acquire each socket's write lock, and queue the write for each of 2,400 connections before it could return control to the event loop. While the broadcast loop was executing, the event loop was blocked: the Kafka consumer's message callback could not fire, new WebSocket connection handshakes could not be processed, and HTTP health checks from the load balancer were not answered. By 10:30 AM, the Kafka consumer lag had grown to 47 minutes — the dashboard was showing orders from nearly an hour ago as if they were live. The load balancer had begun marking the instance unhealthy because health checks were timing out during broadcast cycles, but Auto Scaling was not triggered because the unhealthy detection threshold was set to three consecutive failures and the health checks were inconsistently answered between broadcast cycles.
The incident lasted six hours before the team scaled up by adding more Node.js instances and routing subsets of partners to each instance, reducing the per-instance connection count to approximately 800. The partial mitigation brought broadcast latency down to around 90ms per event and Kafka lag back under 2 minutes. A proper fix — restructuring the architecture so that Kafka events were published to a Redis pub/sub channel and each WebSocket server instance independently subscribed, processing only the connections it held rather than broadcasting all events to all connections regardless of interest — was implemented over the following two weeks.
The founding session that chose the WebSocket broadcast architecture had documented "WebSocket push for real-time dashboard updates." It had not documented the O(n) fan-out cost per event, the connection count at which broadcast latency would exceed the dashboard's operational freshness requirement, the event loop blocking behavior of synchronous broadcast loops in Node.js, or the architectural threshold at which direct broadcast must be replaced by a pub/sub fan-out model where each server instance handles only its own connections' deliveries rather than all connections across the cluster. Both the in-memory room scaling failure and the broadcast fan-out saturation failure were set during the founding sessions that chose the WebSocket architecture. Neither failure was discoverable from the session records.
Structural properties set by the WebSocket architecture decision
Three structural properties are determined when a team chooses how to manage WebSocket connections and deliver events to clients. None appear explicitly in the founding session that chose Socket.io for the first real-time feature or configured the WebSocket server to broadcast Kafka events to connected clients — they are the operational consequences of design choices made under the pressure of getting a real-time feature shipped.
Property 1: Connection state model and the horizontal scaling ceiling. Every WebSocket server maintains some state per connection: at minimum, the socket reference itself and the authenticated user identity. Beyond that minimum, real-time features typically require shared state that is visible across connections — which users are in which room (collaborative editing), which events a client has seen (notification delivery tracking), what topics a client has subscribed to (filtered dashboard updates), or what the client's current presence status is (online/away/offline indicators). When this shared state lives in the server process's memory, the architecture has two constraints. First, horizontal scaling requires sticky sessions: all connections from a given client must route to the same server instance, because the shared state for that client's session is in that instance's memory and no other instance has access to it. Second, instance replacement requires connection disruption: when a server instance is terminated (for a rolling deploy, a scaling event, or an instance failure), all connections held by that instance are dropped, and the shared state in that instance's memory is lost. Reconnecting clients find a new instance with no knowledge of their previous session state.
The connection state model determines whether the architecture is constrained by any of these limits, and it is chosen implicitly when the team decides where to store room membership, subscription lists, and session context. The three models are: (1) in-memory state with sticky sessions, which scales vertically only and cannot survive instance replacement without state loss — appropriate only for sessions whose shared state is small, ephemeral, and acceptable to lose on disconnect; (2) externalized state with a shared store (Redis, a database), where the server process computes locally but reads and writes session state from a shared layer — sticky sessions can still be used for efficiency (to minimize round-trips to the shared store) but are no longer required for correctness because any instance can reconstruct the client's session state from Redis; (3) stateless connection handling, where the server holds no per-session state and clients carry their own state via signed tokens renewed on each message — this is the correct model for high-connection-count architectures where the overhead of Redis round-trips per message would dominate latency, and where the client can safely hold the authoritative copy of its own state. The load balancer decision record documents the session affinity model; the WebSocket ADR must cross-reference it to document which affinity mechanism is used (IP hash is not stable when the pool size changes; cookie-based affinity is stable but requires the load balancer to issue and respect a stickiness cookie; target group stickiness in AWS ALB uses a duration-based cookie that expires and re-pins, producing disruption at cookie expiry). The caching strategy decision record documents the shared cache layer; when connection state is externalized to Redis, the WebSocket ADR must document the Redis key schema (one key per connection, one key per room, one sorted set per topic subscriber list), the TTL per key (how long the shared store retains state for a disconnected client before purging it), and the Redis availability requirement (if Redis is unavailable, can the server fall back to in-memory state for new connections or must it reject them?).
Property 2: Fan-out model and the event loop saturation ceiling. A WebSocket server that broadcasts each incoming event to a set of connected clients performs fan-out: one event in, N events delivered. The fan-out model — who sends to whom, how, and at what rate — determines the throughput ceiling of the server. There are three fan-out models, each with a different throughput characteristic. Direct broadcast iterates over all connected sockets and delivers the event to each one sequentially. In Node.js, each iteration acquires a write lock on the socket and queues the payload into the socket's send buffer. The iteration is synchronous within the event loop tick: no other event loop callback (incoming message, health check, new connection handshake, timer) can run until the broadcast loop completes. The throughput ceiling of direct broadcast is the time budget the event loop can spend in the broadcast loop without disrupting other operations — approximately 10–20ms in a production server that must also answer health checks and process incoming messages. At 10ms per broadcast and 100 connections, the ceiling is around 1,000 events per second. At 10ms per broadcast and 2,400 connections, the loop takes 340ms, which means the server can handle approximately 3 events per second before the event loop saturates — not 1,000. Topic-based subscription delivers each event only to the clients that have subscribed to that topic. Instead of iterating over all N connections, the server looks up the subscription set for the event's topic and delivers only to those clients. The throughput ceiling is proportional to the number of subscribers per topic rather than the total connection count. This is the correct model for dashboards where different clients want different data subsets (warehouse A sees warehouse A's orders, not all orders), for notification systems where each event is relevant to one or a few clients (a payment notification is delivered to the payer's connection, not to all 50,000 connected users), and for any system where a large fraction of connections would discard an event after receiving it because it is not relevant to them. Pub/sub fan-out distributes the broadcast work across multiple server processes. Each server instance subscribes to a pub/sub broker (Redis pub/sub, Kafka, NATS). When an event is published, the broker delivers it to all subscriber instances. Each instance then delivers the event only to the connections it holds — not to all connections across the cluster. The throughput ceiling is the product of per-instance throughput times the number of instances, because each instance performs only its proportionate share of the fan-out work. The message broker decision record documents the pub/sub infrastructure; the WebSocket ADR must document whether the fan-out model uses the application message broker or a separate WebSocket-specific pub/sub channel (the WebSocket pub/sub channel typically requires much lower latency than the application message broker — Redis pub/sub delivers in under 1ms, Kafka delivers in 5–30ms, and for real-time WebSocket delivery that 30ms latency adds to every event's delivery path). The WebSocket ADR must document the O(n) fan-out cost per event as a concrete calculation: at the projected peak connection count, at what message rate does broadcast latency exceed the event freshness SLA, and at what connection count must the fan-out model be changed from direct broadcast to topic subscription or pub/sub fan-out. Stating this threshold explicitly — "at 200 concurrent connections, direct broadcast latency exceeds 10ms; the fan-out model must change before 200 connections" — converts a future operational discovery into a documented scaling trigger.
The backpressure strategy is a required component of the fan-out model. When a client's network connection is slow or temporarily suspended (a mobile client on a degraded connection, a browser tab in the background with throttled network), the server's WebSocket send buffer for that client fills. Node.js's net.Socket backpressure signals (the write() method returning false to indicate a full buffer) are often not handled in WebSocket server code that was written for the happy path. An unhandled backpressure condition causes the server to buffer an unbounded number of events in memory for the slow client, eventually exhausting server memory. The WebSocket ADR must document the backpressure policy: the maximum send buffer size per connection (after which the server drops events or disconnects the client), the drop policy for buffered events when the buffer limit is reached (drop the oldest, drop all, or close the connection), and the client-side handling of dropped events (the client must be capable of requesting a snapshot of current state on reconnect rather than assuming all events were delivered in order).
Property 3: Reconnection protocol and the deployment disruption surface. Every WebSocket connection is eventually interrupted. The reconnection protocol is the set of behaviors that govern what the client and server do when this happens, and it determines the disruption surface of planned operations (rolling deploys, scaling events) and unplanned events (network partitions, instance crashes). A reconnection protocol designed only for the happy path — brief interruption, same server, state intact — fails in the scenarios that matter operationally.
The client-side reconnection behavior requires four documented parameters: the initial reconnection delay (how long the client waits before the first retry — too short causes a reconnection storm when a server restarts and all clients retry simultaneously; too long degrades the user experience for brief interruptions), the backoff function (exponential with jitter is the standard; the jitter distributes the reconnection storm across a window rather than spiking all clients at the same timestamp), the maximum retry count or total retry duration (after which the client shows an error state rather than retrying indefinitely), and the reconnection target (the same server with the same session cookie for sticky-session architectures, or any available server for stateless architectures). The server-side reconnection handling requires: a session persistence window (how long the server retains session state — room membership, pending operations, subscription list — for a disconnected client before treating them as permanently gone and cleaning up their state), a missed-event buffer (the set of events published to a client's topics while the client was disconnected, retained in a buffer with a defined maximum size and TTL so that a reconnecting client can receive events it missed), and the replay mechanism (the client sends its last-received event ID on reconnect; the server replays buffered events from that ID forward). Without a missed-event buffer and replay mechanism, a reconnecting client has no way to know what happened during the disconnect — it must request a full state snapshot, which is expensive for large state objects, or silently miss the events, which is incorrect for financial data, order status, and any domain where missed events have consequences.
The rolling deploy scenario is the most common source of reconnection disruption and the most preventable. When a server instance is terminated during a rolling deploy, all connections to that instance receive a connection close frame and must reconnect. The disruption surface depends on the drain time: how long the server waits after receiving the termination signal before closing connections. A drain time of zero means all connections drop simultaneously. A drain time that matches the maximum session duration means the deploy takes as long as the longest session — typically hours. The correct approach is a configurable drain time calibrated to the P95 session duration (covering most active sessions) with a hard maximum that bounds the deploy window. During the drain period, the server stops accepting new connections (the load balancer marks it as draining) but maintains existing connections, allowing in-flight operations to complete. The container orchestration decision record documents the pod termination grace period; the WebSocket ADR must document the required drain time as a concrete value, the load balancer configuration change that stops routing new connections to a draining instance before the instance closes existing connections, and the client behavior during a drain — whether clients receive a custom close code indicating that they should reconnect to a different server (allowing clients to reconnect proactively rather than waiting for the hard close at drain expiry). The real-time architecture decision record documents the broader real-time infrastructure; the WebSocket ADR must specify how the reconnection protocol interacts with the fan-out model — if a client reconnects to a different server instance (because its previous instance was terminated), does the new instance correctly re-establish the client's topic subscriptions from the externalized state store, or does the client need to re-send subscription requests after reconnection?
What the founding session records and what it omits
The WebSocket architecture decision is almost always made in the founding sprint for the first real-time feature. The session produces the choice of WebSocket library (Socket.io, ws, uWS), the server-side connection handler, and the first event emission. It records that WebSocket is being used. It does not record the connection state model, the fan-out architecture, or the reconnection protocol.
Three types of AI chat sessions generate these gaps:
The "how do we add real-time collaboration to our product?" session. The engineer wants to add a real-time feature — collaborative editing, live presence, shared state — and asks how to implement it. The session explains WebSocket concepts, recommends Socket.io for its room abstraction and automatic reconnection, produces a code example of a server that maintains rooms and emits events, and describes the client-side Socket.io API. The session does not ask: where will the room state live, and what happens to that state when the server is restarted or scaled horizontally? At what connection count will the room state model require migration from in-memory to a shared store? What is the sticky session configuration required for the load balancer, and what happens to connections when the load balancer's stickiness cookie expires or when the pinned instance is replaced? The session answers the question asked ("how do we implement real-time collaboration") rather than the question that determines operational correctness ("how do we implement real-time collaboration in a way that survives horizontal scaling and rolling deploys without losing user state"). The resulting server works correctly on a single instance and fails when scaled. The API gateway decision record documents the WebSocket proxy configuration; many API gateways (AWS API Gateway, nginx) require explicit configuration to proxy WebSocket connections, and the proxy's timeout settings determine the maximum WebSocket connection duration — a load balancer that closes idle connections after 60 seconds will disconnect users who have not sent a message in 60 seconds even if the server and client both intend the connection to remain open.
The "how do we push real-time data to our dashboard clients?" session. The engineer wants to stream data to clients — order events, metrics updates, notification counts — without clients polling. The session explains WebSocket broadcast, recommends creating a server that maintains a set of connected sockets and iterates over them to send each event, and produces a code example using socket.send() in a loop. The session does not ask: how many concurrent connections will this server handle at projected scale, and what is the broadcast latency at that connection count? What is the event loop blocking behavior of synchronous broadcast in Node.js, and at what connection count does the broadcast loop saturate the event loop enough to affect health checks and incoming connections? Are all events relevant to all clients, or does a topic subscription model reduce fan-out cost? What is the backpressure policy when a client's send buffer is full? The session produces a broadcast implementation that works at low connection counts and saturates at scale in a way that is invisible until the event loop blocks health checks and the load balancer begins marking the instance unhealthy. The observability strategy decision record documents the metrics infrastructure; WebSocket server saturation is not visible in standard application metrics — it requires WebSocket-specific gauges: current connection count, broadcast loop duration histogram, send buffer depth per connection, and Kafka consumer lag when the broadcast loop delays consumer callback processing. Without these metrics, broadcast saturation looks like "the server is slow" rather than "the broadcast loop is blocking the event loop at 2,400 connections."
The "how do we deploy our WebSocket server without disrupting users?" session. The team is about to deploy a new version and asks how to handle the WebSocket connections that are active during the deploy. The session explains connection draining, recommends setting a grace period in the container orchestrator, and describes how to emit a close frame before terminating. The session does not ask: what is the P95 active session duration, and how does the drain time relate to it? What happens to in-flight operations when a connection is closed mid-operation — does the client's automatic reconnection recover the operation, or is it lost? What events did a client miss while disconnected during the deploy, and does the reconnection protocol include a missed-event replay mechanism? What is the load balancer configuration change that must happen before the server starts draining — specifically, the server must be deregistered from the load balancer's target group before it closes connections, so that reconnecting clients are not sent back to the draining server that is about to terminate them? The session produces a drain configuration that avoids the worst disruption — all connections dropping simultaneously — but does not produce a complete reconnection protocol that handles missed events, operation recovery, and load balancer coordination. The circuit breaker and resilience decision record documents the retry and backoff patterns for service clients; the WebSocket reconnection backoff is a client-side implementation of the same pattern, and the WebSocket ADR must document the backoff parameters explicitly — initial delay, multiplier, jitter range, maximum delay, maximum retry count — rather than delegating this to Socket.io's default configuration, which uses 1000ms initial delay with exponential growth and no jitter, producing synchronized reconnection storms when all clients on a server reconnect after a deploy at the same time.
The WhyChose extractor surfaces the founding real-time feature session, the first scale-out incident session, and the first deployment disruption post-mortem session from AI chat history. The WebSocket ADR converts the implicit architectural choices embedded in those sessions — in-memory room state, direct broadcast loop, Socket.io defaults — into a documented connection state model, a fan-out architecture with explicit saturation thresholds, and a reconnection protocol specification, so the next engineer who asks "why did all collaborative sessions lose their state when we deployed?" has the state externalization contract documented, not discovered.
The five sections of a WebSocket ADR
Section 1: Transport model selection and use case boundary. Document why WebSocket was chosen over Server-Sent Events (SSE) or HTTP long polling, with the specific requirements that drove the choice: bidirectional communication (client sends messages to server as well as receiving them), sub-second event delivery requirements, or WebSocket-specific protocol features (binary frames, custom subprotocols). Document the use case boundary — the specific interactions that use WebSocket connections — and the interactions that use standard HTTP requests even when a WebSocket connection is available (a common mistake is to route all API calls through the WebSocket connection once one is established, which creates ordering dependencies and complicates error handling; REST calls should remain as HTTP requests even when a WebSocket connection exists for push events). Document the WebSocket library or framework choice and the rationale: Socket.io provides room abstraction, automatic reconnection, and a polling fallback for environments where WebSocket connections are blocked by proxies; bare ws or uWS provides lower overhead for high-connection-count servers where Socket.io's abstraction cost is measurable. Document the transport fallback policy: whether the architecture requires a WebSocket upgrade to work or whether an SSE or long-polling fallback is supported for clients in environments where WebSocket connections are blocked (corporate proxies that do not support the HTTP Upgrade header, certain load balancers configured to reject non-GET WebSocket upgrades). The authentication strategy decision record documents the authentication mechanism; the WebSocket ADR must document the WebSocket-specific authentication point — the connection upgrade request is the only moment when standard HTTP authentication headers are available; after the upgrade, the protocol switches to the WebSocket framing format and standard HTTP middleware no longer applies. Authenticate at the upgrade request by validating the Authorization header or a short-lived token in the connection URL query parameter, reject the upgrade if authentication fails (returning HTTP 401 before the connection is established), and bind the authenticated user identity to the connection object so that all subsequent messages on that connection are implicitly attributed to the authenticated user without requiring re-authentication on each message.
Section 2: Connection state model and scaling architecture. Document what state the server maintains per connection and where that state is stored: the raw socket reference (always in process memory), the authenticated user identity (in process memory, readable from the connection object), the room or topic subscription list (in process memory for in-memory model, in Redis for externalized model), pending operations (in process memory for in-memory model, in Redis or database for externalized model), and presence data (in Redis or a presence-specific store for any architecture that requires horizontal scaling). Document the horizontal scaling architecture: whether the design requires sticky sessions, the specific load balancer affinity mechanism and its stability properties under pool size changes, and the shared store configuration (Redis cluster, Redis Sentinel, or single-node Redis with a documented availability SLA). Document the Redis adapter configuration for Socket.io architectures: the Redis key namespace, the pub/sub channel naming convention, the heartbeat interval (how frequently Socket.io instances confirm to each other that they are still alive via Redis), and the behavior when Redis becomes unavailable — whether the server falls back to in-memory-only mode (accepting new connections but not delivering cross-instance events) or rejects new connections until Redis recovers. Document the connection count ceiling: the maximum connections per instance at the planned hardware specifications, calculated from the memory budget per connection (the socket object, the subscription list, the send buffer, the application session state), and the CPU budget per connection (the overhead of maintaining the connection's send buffer and processing its incoming messages at the expected message rate). The capacity planning decision record documents the scaling model; the WebSocket ADR must cross-reference it with the specific per-connection resource budget, because WebSocket servers scale differently from stateless HTTP servers — adding an instance to a WebSocket pool does not immediately reduce the load on existing instances, because existing connections remain pinned to their original instances until they disconnect and reconnect.
Section 3: Fan-out architecture and backpressure policy. Document the fan-out model: direct broadcast (all connected clients receive all events, appropriate only for fully public data at low connection counts with a documented saturation threshold), topic subscription (clients subscribe to specific topics and receive only events for their subscribed topics, appropriate for filtered data at medium connection counts), or pub/sub fan-out (events are published to a broker and delivered to instances, which deliver only to their held connections, appropriate for high connection counts where per-instance fan-out must be bounded). Document the saturation threshold for the chosen fan-out model: the connection count at which the broadcast latency for a single event exceeds the event freshness SLA, calculated as the projected serialization time per event multiplied by the connection count divided by the available CPU budget per event loop tick. State this threshold as a concrete number — "at 150 concurrent connections, per-event broadcast latency exceeds 10ms at the projected event rate; the fan-out model must be replaced before reaching 150 connections" — so that the threshold is a known operational trigger rather than a post-incident discovery. Document the topic subscription model for subscription-based architectures: the topic namespace (how topics are named and scoped — by entity ID, by data category, by user organization), the subscription protocol (the message format the client sends to subscribe and unsubscribe, and the server's confirmation response), the subscription state persistence (whether subscriptions survive reconnections automatically using the externalized state store, or whether the client must re-subscribe after each reconnect), and the authorization model (which clients are permitted to subscribe to which topics — a client must not be able to subscribe to another user's private data by guessing a topic name). Document the backpressure policy: the maximum send buffer size per connection in bytes (the ws and uWS libraries expose the bufferedAmount property; Socket.io does not expose this directly, requiring the underlying socket to be accessed), the behavior when the buffer reaches the maximum (drop new events for that connection, disconnect the connection, or apply connection-level rate limiting), and the client-side handling contract for dropped events (the client must implement a state reconciliation request that fetches the current server-side state snapshot when it detects that events may have been dropped, rather than assuming its local state is consistent with the server's state).
Section 4: Reconnection protocol and deployment drain procedure. Document the reconnection protocol as a complete specification covering both the client and server sides. On the client side: the initial reconnection delay, the backoff function and its parameters (multiplier, maximum delay, jitter range), the maximum retry duration after which the client presents an error state and stops retrying, the reconnection target (same server via session cookie for sticky architectures, any server for stateless architectures), and the state reconciliation request the client sends after reconnecting (including the last-received event ID for missed-event replay, or a request for a full state snapshot if the missed-event buffer has expired). On the server side: the session persistence window (how long the server retains session state in the externalized store for a disconnected client before purging it — this must be longer than the maximum expected reconnection time, including the maximum backoff delay, to prevent a client in final retry from reconnecting to find its session already purged), the missed-event buffer (the maximum number of events retained per topic per disconnected client, and the maximum buffer TTL), the replay mechanism (the server replays all buffered events newer than the client's last-received event ID on reconnect, up to the buffer limit), and the buffer overflow policy (when the missed-event buffer overflows because the client was disconnected for longer than the TTL or missed more events than the maximum count, the server sends a snapshot request response indicating that the client must fetch a full state snapshot rather than a replay). Document the deployment drain procedure: the termination signal handling (on SIGTERM, the server begins draining by deregistering from the load balancer target group, waiting for the load balancer to stop routing new connections to it, then beginning a countdown to the hard close deadline), the load balancer deregistration mechanism and wait time (AWS ALB deregistration delay, Kubernetes readiness probe failure propagation time), the drain time (the period between deregistration and hard close, calibrated to the P95 active session duration so that most sessions complete naturally, with a hard maximum that bounds the deploy window regardless of long-lived connections), the close code sent to connections that reach the hard close deadline (a custom close code that instructs the client to reconnect to a different server rather than the same server it was on), and the drain monitoring (a metric that tracks the number of connections remaining on the draining instance, alerting if the drain count reaches zero before the hard close deadline — indicating all connections have migrated — or if it has not reached zero by the deadline minus a margin — indicating long-lived connections that may need manual investigation). The container orchestration decision record documents the pod termination grace period; this value must be set to the drain time plus the load balancer deregistration wait time plus a safety margin, so that the orchestrator does not send SIGKILL before the drain procedure completes.
Section 5: WebSocket observability and connection health model. Document the WebSocket-specific monitoring surfaces that are not captured by standard HTTP application metrics. The connection count gauge tracks the current number of active WebSocket connections per server instance and across the cluster. This metric drives the horizontal scaling trigger (when per-instance connection count approaches the ceiling documented in section 2) and the fan-out saturation threshold (when cluster-wide connection count approaches the threshold documented in section 3). The broadcast latency histogram tracks the time from event receipt to completion of the broadcast loop for each event, segmented by fan-out model and connection count bin. A broadcast latency that exceeds the event freshness SLA at a specific connection count confirms that the saturation threshold has been reached and the fan-out model must be changed. The send buffer depth gauge tracks the number of bytes buffered for pending delivery to slow clients. A send buffer depth that grows monotonically for specific connections identifies the clients whose network connections cannot absorb events at the rate they are being generated — the first signal to detect before the backpressure policy triggers. The reconnection rate counter tracks the number of client reconnections per minute across the cluster. An elevated reconnection rate during a non-deploy period indicates network instability, load balancer timeout misconfiguration, or server-side errors that are causing connections to close unexpectedly. A spike in reconnection rate during a deploy confirms that the drain procedure is causing connection disruption — if the reconnection rate spike coincides with the expected drain period and returns to baseline before the drain timer expires, the drain procedure is working as designed. The observability strategy decision record documents the metrics pipeline; WebSocket-specific metrics are typically emitted as custom application metrics rather than captured by infrastructure monitoring, because the metrics that matter (broadcast loop duration, send buffer depth, reconnection rate with cause code) are not observable at the network layer. Document the on-call runbook for each alert: elevated broadcast latency → check connection count against saturation threshold, check event rate against broadcast rate limit, check for slow clients with large send buffers; elevated reconnection rate outside deploy windows → check load balancer timeout configuration, check server error logs for unexpected connection close reasons, check network path between clients and server for packet loss. None of these monitoring surfaces are configured in the founding session that chose Socket.io for the first real-time feature. The founding session produces working code for a single-instance deployment. The WebSocket ADR produces the operational model that makes the architecture manageable as connection count grows, deploys become more frequent, and the real-time feature becomes a critical path for users who notice when it stops working.
None of these five sections appear in the session that added the first Socket.io room or the first broadcast loop. The session records that WebSocket is being used and that in-memory rooms or direct broadcast are the implementation. It does not record the Redis adapter requirement before the first horizontal scale-out, the O(n) saturation threshold before the first promotional campaign, or the drain procedure before the first rolling deploy disrupts active user sessions. The WebSocket ADR is the document that converts the implicit architectural choices from those founding sessions into the operational parameters that determine whether the team discovers horizontal scaling limitations by running a two-day Redis adapter integration proactively, or by spending a week investigating why collaborative sessions lose their state every time the product is deployed. The WhyChose extractor recovers the founding real-time feature session, the first scale-out incident session, and the first deployment disruption post-mortem from AI chat history; the WebSocket ADR extracts the durable architectural constraints from those sessions and documents them where engineers encounter them — next to the Socket.io configuration and the load balancer settings — not in a Slack thread from the month the feature launched and an incident post-mortem that described symptoms without documenting the structural property that caused them.
FAQs
When should a team choose WebSockets over Server-Sent Events or HTTP long polling?
WebSockets are appropriate when the application requires bidirectional communication — the client sends messages to the server and the server sends messages to the client over the same connection — and when the message rate or latency requirement cannot be satisfied by repeated HTTP requests. Canonical WebSocket use cases: collaborative editing where clients send operations and receive peers' operations; multiplayer game state synchronization at 30–60 updates per second; live auction bidding where clients submit bids and receive competing bids sub-second.
Server-Sent Events (SSE) are the better choice when communication is unidirectional — the server pushes updates to clients but clients do not send data over the same connection. SSE connections are plain HTTP responses with streaming bodies, which means they work through HTTP/1.1 proxies without upgrade negotiation, they support automatic reconnection with Last-Event-ID for replay without application-level protocol design, and they are trivially horizontally scalable because there is no per-connection state beyond the subscription. SSE is correct for live dashboards, notification feeds, and status pages. HTTP long polling is correct when WebSocket or SSE cannot be guaranteed (corporate proxies that strip upgrade headers) and when event frequency is low enough that per-request overhead is acceptable. The team that chooses WebSocket for a unidirectional server-push use case takes on all of WebSocket's horizontal scaling complexity — sticky sessions, connection state externalization, reconnection protocol — without needing it, when SSE would have delivered the same functionality with native HTTP semantics.
What is the correct way to horizontally scale a WebSocket server?
There are two architecturally distinct approaches, determined by what connection state the server maintains. The first is sticky session routing with externalized state. All connections from a client route to the same server instance via cookie-based affinity or a client-assigned server ID. The server processes connections locally, but all shared state — room membership, subscription lists, presence — is stored in Redis rather than process memory. Socket.io's Redis adapter implements this model: room membership is stored in Redis and events are delivered cross-instance via Redis pub/sub. Cookie-based sticky sessions are stable under pool size changes; IP hash is not (redistributes existing connections when the pool changes). The second approach is stateless connection handling with a pub/sub fan-out layer. Each WebSocket server maintains no per-session state beyond the raw socket. Clients subscribe to topics by sending subscribe messages; the server registers subscriptions in a pub/sub broker (Redis, NATS). Events are published to the broker and delivered to the instances that have subscribers for each topic. This approach does not require sticky sessions and scales linearly with instance count.
The wrong architecture is in-memory room state without sticky sessions: if the load balancer distributes connections round-robin and two users in the same document land on different instances, they are in separate in-memory rooms and cannot see each other's events. This is the default failure mode for Socket.io deployments that add a second instance without a Redis adapter.
What should a WebSocket ADR document that a general API design decision does not?
A general API design decision records the protocol choice, endpoint naming, authentication mechanism, and versioning strategy. A WebSocket ADR must document five structural properties: (1) the connection state model — what state the server maintains per connection and where it lives (process memory, Redis, client-side token), with the horizontal scaling model that the state location requires; (2) the fan-out model — direct broadcast, topic subscription, or pub/sub fan-out, with the saturation threshold at which the model must change (e.g., "direct broadcast exceeds 10ms latency above 150 connections"); (3) the backpressure policy — the maximum send buffer per connection and the drop or disconnect behavior when the buffer fills; (4) the reconnection protocol — client-side backoff parameters, server-side session persistence duration, missed-event buffer size and TTL, replay mechanism for reconnecting clients, and the load balancer deregistration procedure for rolling deploys; (5) the observability model — connection count gauge, broadcast latency histogram, send buffer depth gauge, and reconnection rate counter, with alert thresholds and on-call runbook per alert.
None of these appear in the founding session that chose Socket.io for the first real-time feature. All of them determine whether horizontal scaling requires a two-day Redis adapter integration or a six-week architecture migration, and whether a rolling deploy causes a 30-second user disruption or a simultaneous state loss for every active collaborative session.