Когда пользователь открывает чат и сразу видит чужое сообщение — без перезагрузки, без кнопки «обновить» — за этим почти всегда стоит WebSocket. Эта технология уже больше десяти лет решает одну конкретную задачу: держать постоянный канал связи между браузером и сервером. Разберём, как это работает изнутри, где применять и где не нужно.

Почему обычный HTTP не подходит для real-time

HTTP построен на модели запрос-ответ. Клиент спрашивает — сервер отвечает — соединение закрывается. Для большинства задач это отлично: загрузить страницу, отправить форму, получить JSON с товарами.

Но что если данные меняются на сервере и нужно сразу показать это пользователю? Есть несколько подходов:

Polling — клиент каждые N секунд отправляет запрос: «Есть что-то новое?». Просто, но расточительно. При 1000 пользователях и интервале 2 секунды — 500 запросов в секунду, большинство из которых вернут пустой ответ.

Long polling — клиент отправляет запрос, сервер держит соединение открытым, пока не появятся данные. Лучше, но всё равно костыль: после каждого ответа нужно открывать новое соединение.

Server-Sent Events (SSE) — сервер может слать данные в одну сторону через постоянное HTTP-соединение. Хорошо для уведомлений, но нет двустороннего канала.

WebSocket — полнодуплексный протокол. Одно соединение, данные летят в обе стороны, когда нужно.

Как работает WebSocket-рукопожатие

WebSocket начинается с обычного HTTP-запроса — это называется handshake. Клиент отправляет заголовки:

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

Сервер отвечает:

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

Код 101 означает: «Переключаем протокол». После этого HTTP уходит, и между клиентом и сервером остаётся сырое TCP-соединение, по которому ходят WebSocket-фреймы.

Всё это происходит за миллисекунды и невидимо для пользователя.

Структура WebSocket-фрейма

Данные передаются не как HTTP-тело, а как бинарные фреймы. Каждый фрейм содержит:

  • Опкод — тип данных: текст (0x1), бинарные данные (0x2), ping (0x9), pong (0xA), закрытие соединения (0x8)
  • Маску — клиент обязан маскировать данные, сервер — нет (защита от прокси-атак)
  • Длину payload — 7 бит для коротких сообщений, с расширением до 64 бит для больших
  • Сами данные

Заголовок фрейма занимает 2-10 байт против сотен байт HTTP-заголовков. На высокочастотных обновлениях экономия ощутимая.

Пишем простой WebSocket-сервер

На Node.js с библиотекой ws:

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws) => {
  console.log('Клиент подключился');

  ws.on('message', (data) => {
    const message = JSON.parse(data);
    
    // Рассылаем всем подключённым клиентам
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify({
          user: message.user,
          text: message.text,
          time: Date.now()
        }));
      }
    });
  });

  ws.on('close', () => {
    console.log('Клиент отключился');
  });
});

Клиентская сторона:

const socket = new WebSocket('wss://example.com/ws');

socket.addEventListener('open', () => {
  socket.send(JSON.stringify({ user: 'Иван', text: 'Привет всем' }));
});

socket.addEventListener('message', (event) => {
  const message = JSON.parse(event.data);
  appendMessageToChat(message);
});

socket.addEventListener('close', (event) => {
  console.log('Соединение закрыто:', event.code, event.reason);
  // Переподключаемся через 3 секунды
  setTimeout(reconnect, 3000);
});

Важный момент в клиентском коде — обработка close и переподключение. Соединения рвутся: мобильные сети, прокси с таймаутами, перезагрузка сервера. Без автоматического переподключения пользователь просто потеряет связь и не поймёт почему.

Где WebSocket нужен, а где нет

WebSocket решает конкретные задачи. Не нужно его применять везде, где есть API.

Нужен:

  • Чаты и мессенджеры
  • Многопользовательские игры
  • Совместное редактирование документов (как в Google Docs)
  • Биржевые котировки и спортивные счета
  • Уведомления в реальном времени
  • Доски мониторинга с живыми графиками
  • Трекинг местоположения (курьеры, такси)

Не нужен:

  • Обычная загрузка данных — используйте REST или GraphQL
  • Редкие обновления раз в несколько минут — SSE или polling будут проще
  • Файловые загрузки — HTTP справляется лучше
  • Простые CRUD-операции

Если пользователь не будет видеть изменения раньше, чем через 30 секунд — WebSocket избыточен. Polling с интервалом 30 секунд создаст в разы меньше сложности.

Масштабирование: главная ловушка

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

Решение — pub/sub через Redis:

const redis = require('redis');
const subscriber = redis.createClient();
const publisher = redis.createClient();

// Подписываемся на канал
subscriber.subscribe('chat:messages');

subscriber.on('message', (channel, data) => {
  // Рассылаем всем локальным клиентам
  wss.clients.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  });
});

// При получении сообщения от WebSocket-клиента
ws.on('message', (data) => {
  // Публикуем в Redis — все серверы получат и раздадут своим клиентам
  publisher.publish('chat:messages', data);
});

Такой подход работает на любом количестве серверов. Каждый сервер подписан на общий Redis-канал и ретранслирует сообщения своим подключённым клиентам.

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

WebSocket не поддерживает кастомные HTTP-заголовки при handshake из браузера. Это создаёт неочевидную проблему с передачей токена.

Есть три подхода:

Query string — простой, но токен виден в логах:

wss://example.com/ws?token=eyJhbGci...

Cookie — браузер автоматически отправляет cookies при handshake. Удобно, но нужно настроить CORS и SameSite:

// Сервер читает cookie во время handshake
wss.on('connection', (ws, request) => {
  const cookies = parseCookies(request.headers.cookie);
  const token = cookies['auth_token'];
  // Верифицируем...
});

Первое сообщение — соединение открывается, первым делом клиент отправляет токен, сервер верифицирует и только потом начинает обработку других сообщений. Самый чистый вариант для мобильных приложений.

Heartbeat — держим соединение живым

Прокси-серверы и балансировщики нагрузки закрывают неактивные TCP-соединения через 30-120 секунд. Если пользователь просто смотрит на экран без действий — соединение тихо умрёт.

WebSocket предусматривает механизм ping/pong:

// Сервер каждые 30 секунд пингует клиентов
const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      return ws.terminate(); // Клиент не ответил на прошлый ping — отключаем
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => { ws.isAlive = true; }); // Клиент жив
});

Браузеры автоматически отвечают на ping понгом — это встроено в стандарт. На уровне приложения можно добавить свой heartbeat поверх текстовых сообщений для совместимости с некоторыми прокси.

WSS — только зашифрованный протокол

wss:// — это WebSocket поверх TLS, аналог HTTPS. Использовать ws:// (без шифрования) в продакшне нельзя по нескольким причинам:

  • Браузеры блокируют смешанный контент: если сайт на HTTPS, ws:// соединение не установится
  • Данные передаются в открытом виде через весь интернет
  • Многие корпоративные прокси блокируют незашифрованный WebSocket

Если стоит Nginx как reverse proxy, конфигурация для проброса WebSocket:

location /ws {
    proxy_pass http://localhost: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 86400; # 24 часа — не закрывать долгие соединения
}

Заголовки Upgrade и Connection обязательны — без них Nginx не переключит протокол и вернёт 400.

Socket.IO — библиотека или протокол?

Socket.IO — это не WebSocket, хотя использует его как основной транспорт. По сути это протокол поверх WebSocket с дополнительными возможностями:

  • Автоматический fallback на long polling, если WebSocket недоступен
  • Комнаты и пространства имён
  • Автопереподключение
  • Подтверждения доставки
  • Бинарные данные

Недостаток: Socket.IO клиент нельзя заменить нативным WebSocket — они несовместимы. Если подключиться к Socket.IO-серверу обычным WebSocket-клиентом, ничего не заработает.

Для большинства проектов нативный WebSocket с правильно написанным кодом переподключения покрывает все потребности без лишних зависимостей.

Мониторинг WebSocket в продакшне

WebSocket-соединения накапливаются. Нужно отслеживать:

  • Количество активных соединений — резкий рост может означать утечку или DDoS
  • Время жизни соединений — если большинство живут меньше секунды, что-то не так с переподключением
  • Задержку доставки сообщений — добавьте timestamp в сообщение и измеряйте разницу на клиенте
  • Частоту разрывов — много close с кодом 1006 (abnormal closure) говорит о проблемах с сетью или сервером

Prometheus + Grafana стандартная связка для этого. Счётчик открытых соединений:

const openConnections = new promClient.Gauge({
  name: 'websocket_connections_active',
  help: 'Active WebSocket connections'
});

wss.on('connection', (ws) => {
  openConnections.inc();
  ws.on('close', () => openConnections.dec());
});

Когда лучше взять готовое

Строить WebSocket-инфраструктуру с нуля имеет смысл, если это ядро продукта. Для большинства бизнес-задач — чат поддержки, уведомления, простой дашборд — есть готовые сервисы: Pusher, Ably, Centrifugo.

Centrifugo, например, open-source, ставится как отдельный сервис и берёт на себя всё: масштабирование, аутентификацию через JWT, историю сообщений, presence (кто онлайн). Бэкенд просто публикует события через HTTP API, а Centrifugo раздаёт их подписчикам.

Если нужна реализация под конкретный проект — Telegram-бот с live-обновлениями, корпоративный чат, дашборд мониторинга — команда REEXY разберётся с архитектурой и выберет подходящий инструмент под задачу и бюджет.

Коды закрытия соединения

Когда соединение закрывается, сервер и клиент обмениваются кодом причины. Основные:

Код Значение
1000 Нормальное закрытие
1001 Уходит сервер или клиент (браузер закрыт)
1002 Ошибка протокола
1003 Получены данные неподдерживаемого типа
1006 Аномальное закрытие (нет close-фрейма)
1011 Неожиданная ошибка на сервере
4000-4999 Пользовательские коды

Коды 4000-4999 зарезервированы для приложений. Удобно использовать их для семантических причин отключения: 4001 — не аутентифицирован, 4002 — достигнут лимит соединений.

Тестирование WebSocket

Для ручного тестирования — websocat, утилита командной строки:

websocat wss://example.com/ws
# Дальше просто вводишь JSON и смотришь ответы

Postman тоже поддерживает WebSocket с версии 9. Для автоматизированных тестов на Node.js удобен пакет ws — тот же, что используется для сервера:

const ws = new WebSocket('ws://localhost:8080');

ws.on('open', () => {
  ws.send(JSON.stringify({ type: 'ping' }));
});

ws.on('message', (data) => {
  const response = JSON.parse(data);
  assert.strictEqual(response.type, 'pong');
  ws.close();
});

WebSocket — зрелая технология с понятным поведением. Главное — не применять там, где достаточно обычного HTTP, и правильно обрабатывать переподключение на клиенте. Остальное решается стандартными инструментами.