Когда пользователь открывает чат и сразу видит чужое сообщение — без перезагрузки, без кнопки «обновить» — за этим почти всегда стоит 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, и правильно обрабатывать переподключение на клиенте. Остальное решается стандартными инструментами.