⚠️ Maintenance. Notice: the site is currently in maintenance mode, so the amount of available material is temporarily limited.
Home / Tutorials / ScalingМасштабирование

Scaling WebSockets across many nodesМасштабирование WebSocket на несколько узлов

One process is not enoughОдного процесса мало

A single Node process can hold tens of thousands of idle WebSockets, but you'll outgrow it — for capacity, for zero-downtime deploys, and for fault tolerance. The moment you run two or more instances behind a load balancer, a hard question appears: if user A is connected to node 1 and user B to node 2, how does a message from A reach B? Two pieces solve it: sticky routing and a backplane.

Один процесс Node может держать десятки тысяч простаивающих WebSocket-ов, но вы его перерастёте — ради ёмкости, выкатки без простоя и отказоустойчивости. Как только за балансировщиком работает два и более экземпляра, возникает сложный вопрос: если пользователь A подключён к узлу 1, а B — к узлу 2, как сообщение от A дойдёт до B? Решают две вещи: sticky-маршрутизация и backplane.

Sticky sessionsSticky-сессии

A WebSocket is a long-lived TCP connection — it must stay pinned to the node that accepted the upgrade. Configure your load balancer for connection affinity (by IP hash or a cookie). With Caddy or nginx in front, make sure the upgrade headers are forwarded:

nginx.conf
upstream ws_pool {
    ip_hash;                 # pin a client to one node
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

location /v1 {
    proxy_pass http://ws_pool;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 75s;  # longer than your heartbeat
}

WebSocket — это долгоживущее TCP-соединение, оно должно оставаться привязанным к узлу, принявшему upgrade. Настройте балансировщик на привязку соединения (по хешу IP или cookie). Если впереди Caddy или nginx, убедитесь, что заголовки upgrade пробрасываются:

nginx.conf
upstream ws_pool {
    ip_hash;                 # привязать клиента к узлу
    server 10.0.0.11:8080;
    server 10.0.0.12:8080;
}

location /v1 {
    proxy_pass http://ws_pool;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 75s;  # дольше вашего heartbeat
}

A pub/sub backplaneШина pub/sub

To deliver across nodes, every instance subscribes to a shared channel (Redis, NATS, Kafka…). When a node receives a message it should broadcast, it publishes to the channel; every node — including itself — gets it and fans out to its own local clients.

backplane.js
import { createClient } from "redis";

const pub = createClient();
const sub = pub.duplicate();
await pub.connect(); await sub.connect();

// 1) Re-broadcast anything published by any node
await sub.subscribe("room:lobby", (raw) => {
  for (const ws of wss.clients)
    if (ws.readyState === 1) ws.send(raw);
});

// 2) When a local client speaks, publish to the channel
function broadcast(msg) {
  pub.publish("room:lobby", JSON.stringify(msg));
}
This is exactly what libraries like socket.io's Redis adapter do under the hood. Understanding the moving parts lets you debug it when the abstraction leaks.

Чтобы доставлять сообщения между узлами, каждый экземпляр подписывается на общий канал (Redis, NATS, Kafka…). Получив сообщение для широковещания, узел публикует его в канал; каждый узел — включая себя — получает его и раздаёт своим локальным клиентам.

backplane.js
import { createClient } from "redis";

const pub = createClient();
const sub = pub.duplicate();
await pub.connect(); await sub.connect();

// 1) Раздаём всё, что опубликовал любой узел
await sub.subscribe("room:lobby", (raw) => {
  for (const ws of wss.clients)
    if (ws.readyState === 1) ws.send(raw);
});

// 2) Когда говорит локальный клиент — публикуем в канал
function broadcast(msg) {
  pub.publish("room:lobby", JSON.stringify(msg));
}
Именно это под капотом делает, например, Redis-адаптер socket.io. Понимание устройства позволяет отлаживать систему, когда абстракция «протекает».

Graceful shutdownАккуратное завершение

During a rolling deploy you don't want to yank thousands of sockets at once. On SIGTERM, stop accepting new connections, tell existing clients to reconnect, then close with a clear code so their backoff logic spreads the reconnection storm:

shutdown.js
process.on("SIGTERM", async () => {
  server.close();                 // stop new upgrades
  for (const ws of wss.clients) {
    ws.send('{"type":"reconnect"}');
    ws.close(1012, "server restarting"); // 1012 = Service Restart
  }
  await drainAndExit();
});

Во время плавной выкатки не стоит обрывать тысячи сокетов разом. По SIGTERM перестаньте принимать новые соединения, попросите клиентов переподключиться и закройте с понятным кодом, чтобы их логика backoff «размазала» шторм переподключений:

shutdown.js
process.on("SIGTERM", async () => {
  server.close();                 // прекратить новые upgrade
  for (const ws of wss.clients) {
    ws.send('{"type":"reconnect"}');
    ws.close(1012, "server restarting"); // 1012 = Service Restart
  }
  await drainAndExit();
});

Production checklistЧек-лист для продакшена

  • Sticky routing at the load balancer, proxy timeouts above your heartbeat interval.
  • A pub/sub backplane for cross-node delivery; namespace channels per room/topic.
  • Per-connection memory budget — measure it, then cap connections per node.
  • Heartbeats to evict dead sockets and free memory.
  • Graceful shutdown with a clear close code and client-side backoff.
  • Metrics: open connections, messages/s, bufferedAmount, backplane lag.
  • Sticky-маршрутизация на балансировщике, таймауты прокси больше интервала heartbeat.
  • Шина pub/sub для доставки между узлами; разносите каналы по комнатам/темам.
  • Бюджет памяти на соединение — измерьте его и ограничьте число соединений на узел.
  • Heartbeat для вытеснения мёртвых сокетов и освобождения памяти.
  • Аккуратное завершение с понятным кодом закрытия и backoff на клиенте.
  • Метрики: открытые соединения, сообщений/с, bufferedAmount, задержка шины.