WebSocket protocol — full-duplex bidirectional communication. Upgrade from HTTP/1.1 to WS frames. Use cases: chat, real-time dashboards, collaborative editors, live trading. Heartbeats, reconnection, scaling via sticky sessions and Redis pub/sub backplane. Includes 4 scenarios: handshake (HTTP Upgrade -> 101 Switching Protocols), bidirectional message flow (typing/presence/text/binary frames), broadcast via Redis pub/sub fan-out across gateway nodes, and connection drop on mobile with reconnect+resync via last_seq. ADRs cover WebSocket vs SSE vs long-poll vs WebRTC, plus heartbeat/reconnect/backpressure discipline.
Long-polling умер где-то в 2018-м. Когда нужен двунаправленный канал между браузером и сервером с задержкой порядка RTT, а не двойного RTT — выбор лежит между WebSocket, Server-Sent Events и WebRTC. WebSocket выигрывает дефолтную позицию: chat, multiplayer-игры, collaborative editors (Notion, Figma), trading dashboards, presence-системы, live-уведомления в админках.
Альтернативы:
WebSocket — компромисс: full-duplex, low overhead (2-14 байт/frame), работает через любой HTTP-прокси (после upgrade), browser-native через new WebSocket(url).
«WebSocket = TCP-соединение с handshake через HTTP. После handshake обе стороны могут слать frames когда угодно — нет request/response semantics, latency = RTT.»
Три ключевых факта, которые меняют всё:
Минимальная production-топология real-time gateway:
new WebSocket('wss://...').room:42, все gateway-ноды-подписчики получают и форвардят своим клиентам в этой комнате.Edges — это физические TCP-соединения. Ответы в анимациях идут по тем же edges в обратном направлении (reverse animation), новых edge для них не нужно.
Клиент шлёт обычный HTTP GET с заголовками Upgrade: websocket, Connection: Upgrade, Sec-WebSocket-Key: <base64>, Sec-WebSocket-Version: 13. Сервер отвечает 101 Switching Protocols и Sec-WebSocket-Accept, выведенный из ключа по фиксированному алгоритму (SHA-1 + magic GUID). После 101 тот же TCP socket несёт WS frames вместо HTTP req/resp. Overhead на frame — 2-14 байт против ~500+ у HTTP-запроса. Сразу после handshake запускается heartbeat — server шлёт Ping frame каждые 25-30s, клиент отвечает Pong. Это держит NAT mapping (у carrier-grade NAT idle timeout 30-60s) и быстро детектит мёртвые соединения.
После handshake обе стороны free-form шлют frames. Никакого request/response — клиент может отправить typing event, сервер в это же время push'ит presence-уведомление о новом онлайн-юзере. В демо: client шлёт typing → server одновременно push'ит presence → client шлёт сообщение → server возвращает ACK с sequence_id (нужен для resync при reconnect) → server отправляет 200KB бинарную картинку, фрагментированную на несколько frames. Latency = ~RTT, а не 2*RTT как у polling, потому что серверу не нужно ждать клиентского запроса чтобы доставить данные.
Три клиента одной комнаты (room:42) подключены к разным gateway-нодам — LB размазал sticky sessions. Без backplane сообщение от client-a дойдёт только до тех, кто на gw-1. Решение: gw-1 публикует в Redis PUBLISH room:42 <payload>, gw-2 и gw-3 (которые SUBSCRIBE room:42 при первом клиенте этой комнаты) получают и форвардят своим WS-клиентам. gw-1 фильтрует self, чтобы client-a не получил собственное сообщение через backplane.
Это даёт горизонтальный scale: 3 gateway-ноды × 10K соединений = 30K concurrent users в одной комнате. Важная оговорка: Redis pub/sub — at-most-once. Если subscriber отвалился в момент PUBLISH — сообщение потеряно. Для guaranteed delivery (банкинг, чаты с историей) — Kafka или NATS JetStream с persistent log, чтобы можно было replay.
Самый недооценённый сценарий. Клиент на WiFi переключается на LTE, IP меняется, TCP рвётся. Сервер не знает мгновенно — узнаёт только когда heartbeat-Ping не получит Pong (2 пропуска подряд = ~60s). За это время клиент уже понял (его syscall на send вернул EPIPE), вошёл в reconnect-цикл с exponential backoff + jitter (1s, 2s, 4s, 8s, capped at 30s; jitter ±20% чтобы избежать thundering herd при массовом отвале).
На reconnect клиент шлёт ?last_seq=1050 в URL — это последний sequence_id, который он успел увидеть. LB может закинуть его на другой gateway (старый sticky cookie протух или gw-3 вообще dropped из pool). Новый gateway вытаскивает из Redis-буфера (или Kafka-лога) сообщения с seq > 1050, отправляет одной пачкой — UI догоняет, пользователь не заметил drop. Buffer обычно хранится 60-300 секунд; всё что старше — клиенту придётся подтянуть через REST historical-fetch.
ADR-001: WebSocket vs SSE vs long-poll vs WebRTC. WebSocket — дефолт для интерактивных bidirectional UX. SSE выбираем когда поток односторонний server→client: notifications feed, server logs streaming, live-цены без user-input — проще, работает через любой HTTP-прокси без особых танцев, auto-reconnect встроен в EventSource, не нужен sticky sessions (любой запрос можно роутить куда угодно). Long-polling — fallback для legacy окружений с корпоративными прокси, не умеющими WS upgrade; каждое соединение блокирует worker. WebRTC — когда критичен P2P low-latency UDP (video, voice, real-time gaming): NAT traversal через STUN/TURN, сигналинг через отдельный WS-канал, TURN-релей стоит ~$0.01/GB и нужен примерно для 20% клиентов в NAT-trap.
ADR-002: Heartbeat, reconnect и backpressure. Без этого WS-кластер в продакшене не живёт.
Ping каждые 25-30s, ждёт Pong. Два пропуска подряд — close(). Тайминг подобран под carrier-grade NAT idle timeout (~30-60s). Меньше 20s — лишняя нагрузка, больше 45s — NAT успеет дропнуть mapping.last_seen_sequence_id → server replays из buffer (60-300s window).close(1009) (Message Too Big). Никогда не копить unbounded — один slow consumer OOM-ит весь node, и валится 10K соединений вместо одного.Решение по транспорту pub/sub: Redis для дёшево-и-сердито (at-most-once, ~1ms latency, в RAM, легко падает). Kafka/NATS JetStream — когда нужны guaranteed delivery, replay для late subscribers, retention в часы/дни. Цена — выше latency (5-20ms) и сложнее эксплуатация.
guild_id, миллионы concurrent connections. Pub/sub backplane на собственном решении поверх Erlang distribution.origin check при handshake → CSRF через WebSocket: чужой сайт открывает new WebSocket('wss://your-api.com/...') и шлёт команды от лица залогиненного пользователя.?token=...) → токены утекают в access-логи nginx и в Referer headers. Лучше — bearer в первом WS-сообщении после handshake, или короткоживущий signed ticket из отдельного REST-эндпоинта.wss:// (TLS) → плейн-WS режется корпоративными прокси, MITM тривиален, sniffing токенов. В проде всегда WSS.room_id, pub/sub backplane для fan-out