Legan Studio
Все статьи
~ 6 мин чтения

WebSockets и realtime в веб-приложении

Когда нужны WebSockets, чем заменить, как масштабировать, аутентификация, reconnect, очереди сообщений и работа за обратным прокси.

  • веб
  • разработка
  • архитектура

«Сделайте, пожалуйста, чтобы обновлялось в реальном времени» — фраза, после которой у разработчика начинает дёргаться глаз. Realtime — это не просто «WebSocket вместо REST». Это другая модель данных, другая инфраструктура, другие проблемы с масштабированием и отладкой.

Разберём, когда WebSockets действительно нужны, чем их можно заменить и как готовить, чтобы оно работало под нагрузкой.

Когда realtime реально нужен

СценарийНужен realtime
Чат, мессенджерда
Игра, видеоконференцияда
Биржа, котировки, ставкида
Совместное редактирование документада
Уведомления о статусе заказанет, хватит push
Лента новостейнет, хватит ISR + долгий polling
Дашборд с обновлением раз в минутунет, polling раз в 30-60 сек
Чтобы у юзера показалось «новое сообщение»смотря какая частота

Правило: если задержка в 5-10 секунд критична для UX — нужен WebSocket. Если терпимо обновление раз в минуту — обычного HTTP polling хватит.

Альтернативы WebSocket

Long polling

Клиент шлёт запрос, сервер держит соединение, пока не появятся новые данные (или таймаут). Просто, работает за любым прокси, не требует особой инфраструктуры. Минус: каждый клиент держит TCP-коннекшн, как и WS.

Server-Sent Events (SSE)

Однонаправленный поток сервер→клиент по обычному HTTP. Идеально для уведомлений, лент, пуш-нотификаций. Преимущество — работает поверх HTTP/2, переживает прокси, в браузере встроенный API EventSource с автоматическим reconnect.

const events = new EventSource("/api/notifications/stream");
events.onmessage = (e) => {
  const data = JSON.parse(e.data);
  console.log("Notification:", data);
};

Бэкенд:

export async function GET() {
  const stream = new ReadableStream({
    start(controller) {
      const interval = setInterval(() => {
        const data = JSON.stringify({ time: new Date().toISOString() });
        controller.enqueue(`data: ${data}\n\n`);
      }, 5000);
      // на close — clearInterval
    },
  });
  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
    },
  });
}

WebRTC

Для peer-to-peer аудио/видео. Никаких серверов между клиентами (кроме STUN/TURN). О нём — отдельная тема.

WebSocket — основы

const ws = new WebSocket("wss://api.example.ru/ws");

ws.addEventListener("open", () => {
  ws.send(JSON.stringify({ type: "subscribe", channel: "orders" }));
});

ws.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);
  // обработать сообщение
});

ws.addEventListener("close", () => {
  // reconnect через 1-2 секунды
});

wss:// — обязательно (TLS). ws:// запрещён в HTTPS-страницах (mixed content).

Reconnect — обязательно

Соединение упадёт. Часто. Wi-Fi мигнул, мобильный сменил вышку, ноутбук уснул — WebSocket обрывается. Без reconnect клиент тихо отвалится и пользователь решит, что «у вас всё сломалось».

class ReliableSocket {
  private ws?: WebSocket;
  private attempts = 0;
  constructor(private url: string) {
    this.connect();
  }
  private connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => { this.attempts = 0; };
    this.ws.onclose = () => {
      const delay = Math.min(30000, 1000 * 2 ** this.attempts);
      this.attempts++;
      setTimeout(() => this.connect(), delay);
    };
  }
}

Экспоненциальный backoff с верхним ограничением. После reconnect не забыть переподписаться на каналы и догнать пропущенные сообщения (через cursor/last-id).

Аутентификация

Не передавайте токен в URL — он попадёт в логи прокси. Варианты:

  1. Cookie-сессия + WebSocket идёт на тот же origin. Браузер автоматически шлёт cookie в handshake.
  2. Короткоживущий токен — REST-эндпоинт /api/ws/ticket отдаёт ticket с TTL 30 секунд, клиент подключается с ?ticket=.... Сервер проверяет один раз.
  3. Первое сообщение после connect — { type: "auth", token: "..." }. До auth-сообщения сервер ничего не отдаёт.

Вариант 2 — самый чистый. Cookie иногда не работает (cross-origin, мобильные WebView).

Heartbeat

Прокси и роутеры режут TCP-коннекшн при простое (обычно 60-120 секунд). Если ничего не шлётся, соединение тихо умрёт — клиент даже не узнает.

Решение: каждые 25-30 секунд отправлять ping/pong.

setInterval(() => {
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: "ping" }));
}, 25000);

Сервер отвечает pong. Если за 5 секунд нет ответа — закрываем соединение и реконнектимся.

Работа за nginx

location /ws {
  proxy_pass http://backend:8080;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection "upgrade";
  proxy_set_header Host $host;
  proxy_read_timeout 3600s;
  proxy_send_timeout 3600s;
}

Без Upgrade/Connection nginx не пропустит handshake. read_timeout ставим длинный, чтобы heartbeat не дрался с прокси-таймаутами.

Масштабирование

Один Node.js-процесс держит 5-20 тысяч одновременных WebSocket. Хочется больше — нужно несколько процессов, и тут возникает вопрос: как доставить сообщение пользователю, который подключён к процессу №7, если событие пришло в процесс №3?

Ответ — Redis Pub/Sub или Kafka между процессами:

import Redis from "ioredis";
const sub = new Redis();
const pub = new Redis();

sub.subscribe("events");
sub.on("message", (channel, raw) => {
  const msg = JSON.parse(raw);
  // найти WS-соединения нужного userId на этом процессе и отправить
  for (const ws of sockets.get(msg.userId) ?? []) {
    ws.send(raw);
  }
});

// при бизнес-событии
await pub.publish("events", JSON.stringify({ userId: 42, type: "order.updated" }));

Каждый процесс знает только свои подключения и слушает общую шину. Любой процесс может опубликовать — сообщение придёт на все.

Готовые сервисы

Если не хотите держать WS-инфраструктуру сами:

СервисОсобенностьЦена
Pusherклассика, простой APIот $49/мес
Ablyхорошее качество, географияот $29/мес
Centrifugoopen source, можно self-hostбесплатно (свой сервер)
Soketiopen source совместимый с Pusher APIбесплатно
Socket.IO с adapterбиблиотека + самописный сервербесплатно

Centrifugo (российская разработка) — отличный выбор для self-hosted: написан на Go, держит сотни тысяч соединений на одной машине, есть готовый JS-клиент.

Очереди и гарантии доставки

WebSocket по умолчанию — at-most-once. Если клиент отвалился во время отправки, сообщение потеряно.

Если бизнес требует доставки гарантированно — храните события в Postgres/Redis с курсором (last_event_id), при reconnect клиент шлёт ?since=12345, сервер дослывает всё, что было после.

CREATE TABLE events (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT,
  type TEXT,
  payload JSONB,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON events(user_id, id);

Тестирование и отладка

  • DevTools → Network → WS вкладка показывает все сообщения.
  • wscat — CLI-клиент для ручной проверки.
  • k6 или artillery для нагрузочного теста: 1000-10000 одновременных соединений.
  • Sentry умеет ловить WebSocket-ошибки на клиенте.

Итого

Realtime — мощный инструмент, но дорогой в эксплуатации. Прежде чем тащить WebSocket, спросите: реально ли пользователь почувствует разницу между мгновенным и через 30 секунд? В половине случаев — нет, и тогда обычный polling или SSE решат задачу с меньшими затратами на инфраструктуру и поддержку.

Частые вопросы

WebSocket или Server-Sent Events?

SSE — если нужно только сервер → клиент (уведомления, лента событий, прогресс длинной операции). WebSocket — если нужен двусторонний обмен с низкой задержкой (чат, совместное редактирование, игра). SSE проще: работает поверх HTTP/2, нет проблем с прокси, есть встроенный reconnect. WebSocket сложнее, но универсальнее.

Сколько одновременных соединений выдержит один сервер?

Зависит от языка и логики. Голый Node.js без активной обработки — 30-50 тысяч, с реальной нагрузкой — 5-15 тысяч. Go — 100 тысяч и больше на одной машине. Erlang/Elixir с Phoenix Channels — миллионы. Если ожидаете больше 20 тысяч одновременных пользователей, рассматривайте Centrifugo или Phoenix вместо Node.

Как WebSocket работает с serverless (Vercel, Cloudflare Workers)?

Плохо. Vercel Functions не держат долгие соединения. Cloudflare Workers — есть Durable Objects с WS, но это отдельная парадигма со своими ограничениями. Для классических WebSocket нужен либо постоянный процесс (VPS, Kubernetes), либо managed-сервис (Pusher, Ably). Не стройте realtime на serverless без понимания, что вы делаете.

Как авторизовать пользователя в WebSocket?

Лучший вариант — короткоживущий ticket: клиент дёргает REST-эндпоинт, который выдаёт одноразовый токен с TTL 30 секунд, и подключается к WS с этим тикетом. Сервер проверяет тикет один раз при handshake и больше к нему не обращается. Long-lived JWT в URL — плохо, токен утечёт в логи. Cookie работает на same-origin, но не везде.

Что делать, если за корпоративным прокси не работают WebSockets?

Многие корпоративные прокси режут Upgrade-запросы. Решения по убыванию хорошести: SSE поверх HTTP/2 (часто проходит), long polling (медленнее, но работает почти всегда), Cloudflare Tunnel с обходом прокси (если приложение для конкретной компании). Готовые realtime-библиотеки (Socket.IO, Centrifugo) обычно умеют автоматически фолбэчиться на long polling, если WS не подключился.

Сколько стоит держать realtime для 1000 онлайн-пользователей?

На своей инфраструктуре: один VPS за 1000-2000 ₽/мес плюс Redis для координации (300-500 ₽). Pusher/Ably за такую нагрузку — $50-100/мес. Centrifugo на том же VPS бесплатно держит легко. Главные затраты не на железо, а на разработку и поддержку: реконнект, очередь пропущенных сообщений, мониторинг — это месяц работы.

Можно ли использовать WebSocket для уведомлений о новых сообщениях?

Можно, но только пока вкладка открыта. Когда пользователь закрыл сайт — WS закрылся, и вы его не достанете. Для уведомлений на закрытом сайте нужен Web Push или email. Часто комбинируют: пока сайт открыт — WS, иначе — Push после X секунд молчания.