Перейти к содержимому

WebSockets Deep Dive

Зачем знать: WebSocket — основа всех realtime-систем (chat, notifications, trading, multiplayer games, collaborative editing). На уровне Middle 2 Go-разработчик должен уметь не просто открыть соединение через gorilla/websocket, а правильно архитектурировать hub/broadcast, держать 10K+ connections per instance, обрабатывать heartbeats, корректно делать graceful shutdown и понимать протокол RFC 6455 на уровне frames.

  1. Базовая концепция
  2. Глубокое погружение (под капотом)
  3. Gotchas (12+)
  4. Production-практики
  5. Вопросы (25+)
  6. Practice
  7. Источники

HTTP request/response: WebSocket (после handshake):
Client → Request → Client ↔ ↔ ↔ ↔ ↔ ↔ ↔ Server
← Response ← (full duplex, persistent)
Client → Request →
← Response ←

WebSocket — full-duplex, persistent connection поверх TCP с собственным framing и control-сообщениями. Открывается через HTTP Upgrade и затем “съезжает” в свой протокол.

WebSocket-соединение начинается как HTTP/1.1-запрос с заголовком Upgrade:

Client → Server:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Sec-WebSocket-Protocol: chat.v1
Origin: http://example.com

Server → Client:

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

Sec-WebSocket-Accept = base64(SHA1(Sec-WebSocket-Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) — магическая константа из RFC 6455.

После 101 Switching Protocols соединение уходит из HTTP-режима и становится WebSocket.

package main
import (
"context"
"log"
"net/http"
"time"
"github.com/coder/websocket"
)
func handler(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"chat.v1"},
})
if err != nil {
log.Println("accept:", err)
return
}
defer c.CloseNow()
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
for {
typ, data, err := c.Read(ctx)
if err != nil {
log.Println("read:", err)
return
}
// эхо обратно
if err := c.Write(ctx, typ, data); err != nil {
return
}
}
}
func main() {
http.HandleFunc("/ws", handler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
package main
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // ⚠️ в продакшене — валидация Origin
},
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("upgrade:", err)
return
}
defer conn.Close()
for {
mt, msg, err := conn.ReadMessage()
if err != nil {
log.Println("read:", err)
return
}
if err := conn.WriteMessage(mt, msg); err != nil {
return
}
}
}
func main() {
http.HandleFunc("/ws", wsHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}

⚠️ gorilla/websocket в 2024 году перешёл в read-only / maintenance mode. Активно развивается coder/websocket (бывший nhooyr.io/websocket). Для нового кода — coder/websocket.

LibStatusSpecial features
gorilla/websocketmaintenanceIndustry standard, stable API
coder/websocket (nhooyr.io)activeЧище API, лучше context-support, json.Marshal helpers
gobwas/wsactiveLow-alloc, zero-copy, низкоуровневый
fasthttp/websocketactiveДля fasthttp-серверов

0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+

Поля:

  • FIN (1 бит) — последний frame в сообщении (для фрагментации).
  • RSV1-3 (3 бита) — зарезервировано для расширений (например, RSV1=1 у permessage-deflate).
  • Opcode (4 бита):
    • 0x0 — Continuation frame (продолжение фрагментированного сообщения)
    • 0x1 — Text frame (UTF-8)
    • 0x2 — Binary frame
    • 0x8 — Close
    • 0x9 — Ping
    • 0xA — Pong
    • 0x3-0x7, 0xB-0xF — зарезервированы
  • MASK (1 бит) — клиент ОБЯЗАН маскировать payload. Сервер не должен.
  • Payload len (7/7+16/7+64 бит):
    • 0-125 → длина прямо в этом поле
    • 126 → длина в следующих 16 битах
    • 127 → длина в следующих 64 битах
  • Masking key (32 бита) — только если MASK=1.
  • Payload data — байты сообщения.
masked[i] = unmasked[i] XOR masking_key[i % 4]

Назначение — предотвращение cross-protocol attacks, когда browser-driven payload “случайно” выглядит как валидный HTTP-запрос для middlebox proxy и может его атаковать.

⚠️ Сервер НЕ должен маскировать (RFC 6455 §5.3). Если server-frame маскирован — клиент должен закрыть соединение с code 1002 (protocol error).

Control frames имеют opcode ≥ 0x8 и не могут быть фрагментированы, payload ≤ 125 байт.

Client Server
| --- Ping (0x9) --------> |
| <--- Pong (0xA) -------- | ответный pong с тем же payload

В Go обе библиотеки сами отвечают на ping автоматически:

  • coder/websocket: на ping → автоматический pong.
  • gorilla/websocket: handler SetPingHandler(func(string) error { return WriteControl(PongMessage, ...) }) стоит по умолчанию.
A → B: Close frame с code+reason
B → A: Close frame (echo close)
[обе стороны закрывают TCP]

Close codes (RFC 6455 §7.4):

  • 1000 — Normal Closure
  • 1001 — Going Away (server shutdown)
  • 1002 — Protocol error
  • 1003 — Unsupported Data (например, бинарь когда ждут текст)
  • 1006 — Abnormal closure (no Close frame, just TCP RST) — НЕ может быть отправлен, только используется внутренне
  • 1007 — Invalid frame payload data (например, не UTF-8 в text frame)
  • 1008 — Policy violation
  • 1009 — Message too big
  • 1011 — Internal server error
  • 1012 — Service restart
  • 4000-4999 — Application-specific (можно использовать в своих протоколах)

Sec-WebSocket-Protocol — клиент шлёт список предпочитаемых subprotocol, сервер выбирает один (или ни одного). Это позволяет иметь несколько протоколов на одном endpoint (например, chat.v1, chat.v2).

// coder/websocket: разрешённые subprotocols
opts := &websocket.AcceptOptions{
Subprotocols: []string{"chat.v2", "chat.v1"},
}
c, _ := websocket.Accept(w, r, opts)
chosen := c.Subprotocol() // выбранный

Расширение для сжатия payload (DEFLATE). Negotiate через handshake:

Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

⚠️ Compression compute-overhead заметный: для small messages (< 200 байт) compression может быть медленнее, чем raw, плюс CPU нагрузка. Для chat (small payloads) часто отключают.

┌─────────────────────────────┐
│ HTTP Listener │
└──────────────┬──────────────┘
│ accept + upgrade
┌─────────────────────────────┐
│ Hub (broadcast) │
│ ┌──────────────────────┐ │
│ │ map[*Client]bool │ │
│ │ register/unregister │ │
│ │ broadcast chan │ │
│ └──────────────────────┘ │
└────┬────────────────────┬───┘
│ │
┌────────▼────────┐ ┌───────▼────────┐
│ Client A │ │ Client B │
│ ┌───────────┐ │ │ ┌──────────┐ │
│ │ readPump │ │ │ │ readPump │ │
│ └───────────┘ │ │ └──────────┘ │
│ ┌───────────┐ │ │ ┌──────────┐ │
│ │ writePump │ │ │ │writePump │ │
│ └───────────┘ │ │ └──────────┘ │
│ send chan │ │ send chan │
└─────────────────┘ └────────────────┘

⚠️ КЛЮЧЕВОЕ ПРАВИЛО RFC 6455: все frames одного сообщения должны идти подряд. Если несколько goroutines пишут параллельно — frames переплетутся, протокол сломается.

  • gorilla/websocket: один writer и один reader. Конкурентные WriteMessage НЕДОПУСТИМЫ. Решение — все писатели через канал в одну writer-goroutine.
  • coder/websocket: внутренний mutex на write, но они рекомендуют всё равно использовать одну goroutine для writes.
type Hub struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
mu sync.Mutex
}
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
}
func (h *Hub) run() {
for {
select {
case c := <-h.register:
h.mu.Lock()
h.clients[c] = true
h.mu.Unlock()
case c := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
}
h.mu.Unlock()
case msg := <-h.broadcast:
h.mu.Lock()
for c := range h.clients {
select {
case c.send <- msg:
default:
// slow consumer → drop & disconnect
close(c.send)
delete(h.clients, c)
}
}
h.mu.Unlock()
}
}
}
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize) // защита от huge messages
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, msg, err := c.conn.ReadMessage()
if err != nil {
return
}
c.hub.broadcast <- msg
}
}
func (c *Client) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case msg, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
pingPeriod = 30s // отправляем ping каждые 30s
pongWait = 60s // ждём pong не дольше 60s (включает 30s до следующего ping)
writeWait = 10s // макс время на отправку любого frame

Идея: если pong не пришёл за pongWait → клиент мёртв → reset read deadline вызывает таймаут → readPump падает → unregister.

⚠️ В browser JS API нельзя контролировать ping cadence клиента — это делает только сервер. Клиент в JS реагирует на ping автоматически.


  1. ⚠️ CheckOrigin: false по умолчанию в gorilla. Без CheckOrigin любой сайт может открыть WS к вашему серверу (CSRF). Всегда настраивайте:

    upgrader.CheckOrigin = func(r *http.Request) bool {
    origin := r.Header.Get("Origin")
    return origin == "https://myapp.com"
    }
  2. ⚠️ Concurrent writes ломают frame stream. Никогда не вызывайте WriteMessage из разных горутин для одного conn — frames смешаются. Используйте один writePump.

  3. ⚠️ Не закрытый Close frame — TCP RST. Если просто conn.Close() без WriteMessage(CloseMessage, ...) — клиент получит “Abnormal closure (1006)”.

  4. ⚠️ Browser WebSocket API не позволяет custom HTTP headers (кроме Sec-WebSocket-Protocol). Для auth используйте:

    • ?token=... в URL query (но логи!)
    • cookie (если same-domain)
    • Sec-WebSocket-Protocol: bearer.token.<jwt> (hack, но работает)
    • Auth message сразу после connect (первое сообщение)
  5. ⚠️ NGINX без правильных headers роняет WS. Минимум:

    location /ws {
    proxy_pass http://backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    }
  6. ⚠️ ReadLimit обязателен. Без SetReadLimit злой клиент может отправить frame на 2 ГБ → OOM.

  7. ⚠️ TCP_NODELAY и Nagle. WebSocket с маленькими сообщениями страдает от Nagle (TCP буферизует). gorilla и coder уже устанавливают TCP_NODELAY, но если за proxy/LB — проверьте.

  8. ⚠️ Idle connection обрывает L4 LB. AWS NLB закрывает idle 350с, HAProxy дефолт 60с. Без application-level ping → “тихая смерть” соединения. Cadence ping должна быть меньше LB timeout.

  9. ⚠️ HTTP/2 не нативно поддерживает WebSocket. RFC 8441 (CONNECT extension) есть, но мало клиентов/прокси поддерживают. Browser открывает WS поверх HTTP/1.1.

  10. ⚠️ HTTP/3 + WebSocket = RFC 9220. Тоже мало кто поддерживает. На практике WS всё ещё HTTP/1.1.

  11. ⚠️ Sticky sessions для WS с broadcast. Если у вас 3 instance и broadcast по hub’у — клиенты подключённые к instance A не получат сообщений instance B. Решения: pub/sub layer (Redis, NATS) или sticky LB + global broadcast.

  12. ⚠️ Sec-WebSocket-Key проверяется ВЫХОДНО. Если ваш middleware подменяет header — handshake ломается.

  13. ⚠️ Compression bomb. permessage-deflate без context_takeover создаёт CPU pressure, плюс atak “decompression bomb” (маленький запрос → огромный распакованный payload). Лимит на uncompressed size обязателен.

  14. ⚠️ Origin header в WS != Origin в CORS. Не передаётся в preflight. Проверяйте вручную в CheckOrigin.

  15. ⚠️ JSON sending: c.Write(ctx, websocket.MessageText, data) — это правильно, но если для каждого сообщения делать json.Marshal и аллоцировать bytes — GC pressure. На high-throughput используйте sync.Pool для buffer’ов.


package main
import (
"context"
"encoding/json"
"log"
"net/http"
"os"
"os/signal"
"sync"
"syscall"
"time"
"github.com/coder/websocket"
)
type Message struct {
From string `json:"from"`
Body string `json:"body"`
}
type Hub struct {
mu sync.RWMutex
clients map[*Client]struct{}
bcast chan Message
register chan *Client
leave chan *Client
}
type Client struct {
id string
conn *websocket.Conn
send chan Message
hub *Hub
ctx context.Context
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]struct{}),
bcast: make(chan Message, 256),
register: make(chan *Client),
leave: make(chan *Client),
}
}
func (h *Hub) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
h.mu.Lock()
for c := range h.clients {
close(c.send)
}
h.clients = nil
h.mu.Unlock()
return
case c := <-h.register:
h.mu.Lock()
h.clients[c] = struct{}{}
h.mu.Unlock()
case c := <-h.leave:
h.mu.Lock()
if _, ok := h.clients[c]; ok {
delete(h.clients, c)
close(c.send)
}
h.mu.Unlock()
case msg := <-h.bcast:
h.mu.RLock()
for c := range h.clients {
select {
case c.send <- msg:
default:
log.Printf("slow consumer %s, dropping", c.id)
go func(cl *Client) { h.leave <- cl }(c)
}
}
h.mu.RUnlock()
}
}
}
func (c *Client) readLoop() {
defer func() { c.hub.leave <- c }()
c.conn.SetReadLimit(65536) // 64 KiB max msg
for {
_, data, err := c.conn.Read(c.ctx)
if err != nil {
return
}
var m Message
if err := json.Unmarshal(data, &m); err != nil {
continue // skip invalid JSON
}
m.From = c.id
c.hub.bcast <- m
}
}
func (c *Client) writeLoop() {
pingT := time.NewTicker(30 * time.Second)
defer pingT.Stop()
for {
select {
case <-c.ctx.Done():
c.conn.Close(websocket.StatusNormalClosure, "ctx done")
return
case m, ok := <-c.send:
if !ok {
c.conn.Close(websocket.StatusNormalClosure, "")
return
}
wctx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
err := wsjsonWrite(wctx, c.conn, m)
cancel()
if err != nil {
return
}
case <-pingT.C:
wctx, cancel := context.WithTimeout(c.ctx, 10*time.Second)
err := c.conn.Ping(wctx)
cancel()
if err != nil {
return
}
}
}
}
func wsjsonWrite(ctx context.Context, c *websocket.Conn, v any) error {
data, err := json.Marshal(v)
if err != nil {
return err
}
return c.Write(ctx, websocket.MessageText, data)
}
func main() {
ctx, cancel := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer cancel()
hub := NewHub()
go hub.Run(ctx)
mux := http.NewServeMux()
mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
InsecureSkipVerify: false,
OriginPatterns: []string{"app.example.com"},
})
if err != nil {
return
}
cctx, ccancel := context.WithCancel(r.Context())
defer ccancel()
client := &Client{
id: r.URL.Query().Get("uid"),
conn: conn,
send: make(chan Message, 32),
hub: hub,
ctx: cctx,
}
hub.register <- client
go client.writeLoop()
client.readLoop()
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := srv.ListenAndServe(); err != nil &&
err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
sd, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(sd)
log.Println("bye")
_ = os.Stdout
}
// Соединения
const (
maxConnections = 50_000 // ulimit -n / 2 минимум
maxMessageSize = 64 << 10 // 64 KiB
sendChanBuffer = 32 // backpressure
writeTimeout = 10 * time.Second
pingPeriod = 30 * time.Second
pongWait = 60 * time.Second
)
case msg := <-h.bcast:
for c := range h.clients {
select {
case c.send <- msg:
default:
// канал переполнен → клиент медленный
// отключаем его, чтобы не блокировать broadcast
close(c.send)
delete(h.clients, c)
}
}
var (
activeConnections = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "websocket_active_connections",
})
messagesIn = prometheus.NewCounter(prometheus.CounterOpts{
Name: "websocket_messages_in_total",
})
messagesOut = prometheus.NewCounter(prometheus.CounterOpts{
Name: "websocket_messages_out_total",
})
slowConsumers = prometheus.NewCounter(prometheus.CounterOpts{
Name: "websocket_slow_consumers_total",
})
)
class ReconnectingWS {
constructor(url, opts = {}) {
this.url = url;
this.delay = 500;
this.maxDelay = 30000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => { this.delay = 500; };
this.ws.onclose = (e) => {
// экспоненциальный backoff с jitter
const jitter = Math.random() * 0.3 + 0.85;
const wait = this.delay * jitter;
setTimeout(() => this.connect(), wait);
this.delay = Math.min(this.delay * 2, this.maxDelay);
};
this.ws.onmessage = (e) => this.onMessage(e);
}
}

⚠️ Без jitter — все клиенты ретраят синхронно после краша сервера → thundering herd.

Варианты:

  • L4 (TCP) — простой, но без проверки health на app-level.
  • L7 WS-aware (NGINX, HAProxy, Envoy) — может маршрутизировать по URL, добавлять headers.
  • Sticky sessions — клиент всегда попадает на тот же instance (для in-memory state).
  • Pub/Sub layer — Redis Streams / NATS / Kafka — instances обмениваются broadcasted events.
┌──────────┐
Client → │ LB │ (sticky session by cookie/IP)
└────┬─────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│ App 1 │ │ App 2 │ │ App 3 │
└───┬────┘ └───┬────┘ └───┬────┘
│ │ │
└─────► Redis Pub/Sub ◄────┘
(для broadcast между instances)
import "github.com/redis/go-redis/v9"
func subscribe(rdb *redis.Client, hub *Hub) {
pubsub := rdb.Subscribe(context.Background(), "ws:broadcast")
ch := pubsub.Channel()
for msg := range ch {
var m Message
json.Unmarshal([]byte(msg.Payload), &m)
hub.bcast <- m
}
}
func publish(rdb *redis.Client, m Message) {
data, _ := json.Marshal(m)
rdb.Publish(context.Background(), "ws:broadcast", data)
}
СвойствоWebSocketSSEgRPC streaming
НаправлениеBi-directionalServer → ClientЛюбое (uni/bi)
ProtocolRFC 6455 (TCP)HTTP/1.1 plainHTTP/2
Browser supportNativeNativeЧерез gRPC-Web
Binary supportДаНет (только text)Да
ReconnectManualAuto (через EventSource)Manual
Proxy/firewall friendlinessЧасто проблемыХорошоЗависит от HTTP/2 поддержки
Use caseChat, gamesLive feed, notificationsMicroservice streaming
  • Twitch chat — миллионы WS соединений, share хабы по каналам, IRC-like протокол поверх WS.
  • Slack — WebSocket Gateway, server отправляет события, client — minimal traffic.
  • Discord — Gateway WebSocket, server отправляет events, client poll’ит REST для остального.
  • Trading — order book updates через WS (~1000 msg/sec на client).
  • Multiplayer games — WebRTC predпочтительнее для P2P + UDP, но Slack/Discord используют WS.
func (h *Hub) Shutdown(ctx context.Context) {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.clients {
wctx, cancel := context.WithTimeout(ctx, 1*time.Second)
c.conn.Close(websocket.StatusGoingAway, "server shutting down")
cancel()
delete(h.clients, c)
close(c.send)
_ = wctx
}
}

  1. Что такое WebSocket и зачем он нужен? Full-duplex persistent connection поверх TCP, открывается через HTTP Upgrade. Нужен для realtime двусторонней коммуникации (chat, notifications) без polling overhead.

  2. Опиши WebSocket handshake. Client посылает GET ... Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: ... Server отвечает 101 Switching Protocols Sec-WebSocket-Accept: base64(sha1(key + magic)). После 101 соединение покидает HTTP-режим.

  3. Что такое Sec-WebSocket-Key и зачем нужен Accept? Случайный 16-byte nonce, base64. Server считает SHA1(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11") и возвращает в Sec-WebSocket-Accept. Это защита от подмены: middlebox proxy не понимает Upgrade и не сможет сфабриковать accept.

  4. Какие opcodes есть в WebSocket? 0x0 — continuation, 0x1 — text, 0x2 — binary, 0x8 — close, 0x9 — ping, 0xA — pong. Остальные зарезервированы.

  5. Почему клиент должен маскировать payload, а сервер нет? Защита от cross-protocol attacks: middlebox proxy может «увидеть» в unmasked payload что-то похожее на HTTP-запрос и неправильно обработать. Маска предотвращает.

  6. Что такое control frames? Frames с opcode ≥ 0x8 (close, ping, pong). Не могут быть фрагментированы, payload ≤ 125 байт, не зависят от data flow.

  7. Что такое close handshake? Стороны обмениваются Close frames (opcode 0x8) с code+reason, затем закрывают TCP. Если без Close frame → код 1006 (abnormal closure).

  8. Чем concurrency-правила WebSocket отличаются от HTTP? В HTTP каждый запрос/ответ независимый. В WebSocket frames одного сообщения должны идти строго подряд → нельзя писать из нескольких goroutines одновременно.

  9. Как сделать broadcast в WebSocket-сервере? Hub-pattern: одна goroutine хранит map[*Client]struct{}, broadcast канал. При сообщении — итерация по клиентам, отправка в их send-канал. Каждый клиент имеет writePump, читающий send.

  10. Что такое heartbeat в WebSocket? Периодические ping/pong (control frames). Если pong не приходит за timeout → клиент мёртв. Также защита от idle disconnect на LB.

  11. Какую ping-cadence выбрать? Меньше idle timeout вышестоящих LB/proxy. Например, AWS NLB 350s → ping каждые 30s. Default в Go HTTP/2 — 15s.

  12. Что такое permessage-deflate? Расширение сжатия payload (DEFLATE). Negotiate через Sec-WebSocket-Extensions. CPU vs network trade-off. Для small messages часто отключают.

  13. Почему gorilla/websocket в read-only? Maintenance burden, плюс альтернативы (coder/websocket) сделаны с более чистым API. Для нового кода — coder/websocket.

  14. Как сделать authentication для WebSocket? Token в query (?token=...), cookie (если same-site), Sec-WebSocket-Protocol header (hack), auth message сразу после connect. Browser API не позволяет custom HTTP headers.

  15. Почему NGINX часто роняет WS? Без proxy_http_version 1.1, proxy_set_header Upgrade $http_upgrade, proxy_set_header Connection "upgrade" — NGINX считает HTTP/1.0 и закрывает после ответа.

  16. Что такое sticky sessions и зачем нужны для WS? Если состояние в памяти процесса (например, hub знает только своих клиентов), LB должен направлять клиента на тот же instance. Иначе после reconnect клиент попадёт на другой instance и потеряет state.

  17. Как сделать broadcast между несколькими instances? Pub/Sub слой: Redis, NATS, Kafka. Instance публикует событие в shared channel, все instances subscribe → broadcast своим WS-клиентам.

  18. Как масштабировать до 100K connections per instance? Достаточно горутин (1 на conn легковесна, ~4 КБ stack), но: ulimit -n должен быть высокий, ядро tuned (somaxconn, file descriptors), sendChanBuffer небольшой, slow consumer disconnect.

  19. Что такое slow consumer? Клиент, который не успевает читать сообщения — его send-канал переполняется → блокирует broadcast → tail latency растёт для всех. Решение: drop & disconnect.

  20. WS vs SSE: когда выбирать? SSE проще (HTTP/1.1, auto-reconnect, нет специального протокола), но только server→client. WS — bi-directional, binary, sub-protocols. Для server push (live feed) часто SSE достаточно.

  21. WS vs gRPC streaming: когда выбирать? gRPC — для service-to-service (HTTP/2, typed messages). WS — для browser клиентов (gRPC-Web имеет ограничения, нет bidi). Для mobile native — оба варианта валидны.

  22. Какой размер per-message максимум стоит ставить? Зависит от приложения. Для chat 64 KiB достаточно. Для file transfer — выше, но лучше через chunks. SetReadLimit — обязателен.

  23. Что делать с TLS на WebSocket? wss:// = WebSocket over TLS. Поверх стандартного HTTPS handshake. Сертификаты те же. Performance impact — только handshake (~few ms), стабильное соединение overhead negligible.

  24. Как обнаружить, что клиент отвалился (TCP RST)? Чтение даст error (EOF, connection reset). Write даст error (broken pipe). Heartbeat ping пропустит pong → readDeadline истечёт. Если без ping — соединение может «висеть» часами.

  25. Когда WebSocket плохо подходит?

    • Когда proxy/firewall между клиентом и сервером не пропускает Upgrade → нужно fallback на long-polling (socket.io делает).
    • Когда нужна reliability при network failures на mobile → лучше HTTP/2 (gRPC) или WebRTC.
    • Когда payload очень мелкий и не часто → polling может быть дешевле.
  26. Как реализовать backpressure на WebSocket? send chan Message с capacity 32. Если полон — select default → drop сообщения или disconnect клиента.

  27. HTTP/2 поддерживает WebSocket? RFC 8441 (Bootstrapping WebSockets with HTTP/2) описывает CONNECT extension, но мало клиентов и proxy поддерживают. В практике WS — это HTTP/1.1 Upgrade.


Напишите WS-сервер, эхо-отражающий любое сообщение. Используйте coder/websocket. Подключитесь через wscat -c ws://localhost:8080/ws.

Реализуйте чат-сервер: каждое сообщение от любого клиента рассылается всем другим. Используйте hub-pattern.

Добавьте в hub-сервер автоматический ping каждые 30s. Если клиент не отвечает pong за 60s — disconnect. Логируйте active connections.

Добавьте, что если клиент не успевает читать (send chan full) — отключаете его. Проверьте: один медленный клиент не блокирует остальных.

Реализуйте: клиент при handshake шлёт Sec-WebSocket-Protocol: bearer.JWT_TOKEN. Сервер декодирует JWT, проверяет, ассоциирует client с user. Без валидного токена — handshake reject.

Напишите JS-клиента с exponential backoff и jitter. Сервер периодически рестартите, проверьте, что клиент переподключается.

Запустите 2 instances одного сервера, между ними — Redis Pub/Sub. Клиент A подключён к instance 1, клиент B — к instance 2. Сообщение от A должно прийти B.

Реализуйте graceful shutdown: при SIGTERM — closes всем клиентам с code 1001 (Going Away), ждёт до 10s, потом hard-close.

Добавьте /metrics (Prometheus) с метриками: active_connections, messages_in_total, messages_out_total, slow_consumers_total. Откройте Grafana.

С помощью wsbench или tsung сделайте 10K connections к серверу, замеряйте: memory (heap), goroutines, file descriptors.