WebSockets
WebSocket — протокол двунаправленной связи между клиентом и сервером поверх одного длительно существующего соединения.
После установки соединения обе стороны могут отправлять сообщения в любой момент:
Клиент ⇄ WebSocket-соединение ⇄ СерверВ обычном HTTP взаимодействие чаще строится по модели:
Клиент → Запрос → Сервер
Клиент ← Ответ ← СерверПри WebSocket серверу не нужно ждать нового HTTP-запроса, чтобы передать клиенту событие.
Это удобно для систем, где данные должны обновляться почти сразу:
- чатов;
- уведомлений;
- совместного редактирования;
- отображения статуса пользователя;
- игровых событий;
- мониторинга;
- трекинга доставки;
- обновления состояния заказа;
- трансляции показателей в реальном времени.
Пример потока сообщений в чате:
Пользователь A → Сервер: новое сообщение
Сервер → Пользователь B: новое сообщение
Сервер → Пользователь C: новое сообщениеWebSocket не заменяет HTTP полностью. Обычно приложение использует оба протокола:
HTTP:
регистрация, вход, загрузка истории, CRUD-операции
WebSocket:
новые сообщения, статусы, уведомления, обновления в реальном времениСодержание
- Протокол WebSocket
- Отличие WebSocket от HTTP
- WebSocket, polling и Server-Sent Events
- Проектирование сообщений
- Реализация WebSocket-сервера через `ws`
- Рассылка сообщений
- Аутентификация WebSocket
- Heartbeat: Ping и Pong
- Закрытие соединения
- Ограничение размера сообщений
- Rate limiting сообщений
- Backpressure
- Клиентская работа с WebSocket
- Переподключение
- Подтверждение доставки
- Идемпотентность сообщений
- Реализация через Socket.IO
- `ws` или Socket.IO
- Практическое применение: чат
- Практическое применение: real-time уведомления
- Масштабирование WebSocket
- Proxy и WebSocket
- Безопасность WebSocket
- Логи и метрики WebSocket
- Health-check
- Полный минимальный пример на `ws`
- Частые ошибки
- Рекомендуемая архитектура
- Краткая памятка
Протокол WebSocket
WebSocket — отдельный прикладной протокол, стандартизированный для постоянного двунаправленного соединения.
Адрес WebSocket использует схему:
ws://Для защищённого соединения:
wss://Примеры:
ws://localhost:8080
wss://api.example.com/wsСоответствие протоколов:
| HTTP | WebSocket |
|---|---|
http:// |
ws:// |
https:// |
wss:// |
В production следует использовать wss://, потому что он защищает трафик через TLS.
Установка соединения
WebSocket-соединение начинается с обычного HTTP-запроса, который просит сервер переключить протокол.
Клиент отправляет handshake-запрос:
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://app.example.comОсновные заголовки:
Upgrade: websocket
Connection: UpgradeОни сообщают, что клиент хочет перейти с HTTP на WebSocket.
Если сервер принимает запрос, он отвечает:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...Статус:
101 Switching Protocolsозначает успешное переключение протокола.
После этого HTTP-запросы и ответы в данном соединении больше не используются. Клиент и сервер начинают передавать WebSocket-фреймы.
Упрощённая схема:
HTTP handshake
↓
101 Switching Protocols
↓
WebSocket messages
↓
Закрытие соединенияWebSocket-фреймы
После handshake данные передаются небольшими блоками — фреймами.
Основные типы фреймов:
| Тип | Назначение |
|---|---|
| Text | Текстовое сообщение |
| Binary | Бинарные данные |
| Ping | Проверка доступности другой стороны |
| Pong | Ответ на Ping |
| Close | Закрытие соединения |
Браузерный API обычно предоставляет приложению уже собранные сообщения, поэтому вручную работать с отдельными фреймами не требуется.
Текстовое сообщение часто содержит JSON:
{
"type": "chat.message",
"payload": {
"messageId": "message-815",
"text": "Привет!"
}
}WebSocket не требует использовать JSON. Можно передавать:
- текст;
- JSON;
- бинарные данные;
- изображения;
- аудиофрагменты;
- собственный бинарный формат.
Однако формат сообщений и правила взаимодействия приложение должно определить самостоятельно.
Отличие WebSocket от HTTP
Модель взаимодействия
HTTP:
Клиент инициирует запрос
Сервер отвечаетWebSocket:
Клиент может отправить сообщение
Сервер может отправить сообщение
Обе стороны используют одно соединениеВремя жизни соединения
Обычный HTTP-запрос является отдельной операцией:
Запрос → ОтветWebSocket-соединение остаётся открытым:
Соединение
├── сообщение 1
├── сообщение 2
├── сообщение 3
├── сообщение 4
└── закрытиеHTTP также может переиспользовать TCP-соединение, но модель взаимодействия всё равно остаётся запросно-ответной.
Направление передачи данных
HTTP обычно инициируется клиентом.
WebSocket является полнодуплексным:
Клиент → Сервер
Клиент ← СерверОбе стороны могут отправлять сообщения независимо.
Накладные расходы
Каждый HTTP-запрос содержит метод, путь и заголовки:
POST /api/messages HTTP/1.1
Content-Type: application/json
Authorization: Bearer tokenПосле установки WebSocket-соединения сообщения передаются компактными фреймами без полного набора HTTP-заголовков для каждого события.
Это удобно при большом количестве небольших сообщений.
Кэширование
HTTP поддерживает стандартные механизмы кэширования:
Cache-Control
ETag
Last-ModifiedWebSocket-сообщения не используют HTTP-кэширование. Если приложению требуется хранить или повторно получать данные, оно должно реализовать это отдельно.
Статус-коды
HTTP использует статусы:
200 OK
201 Created
400 Bad Request
401 Unauthorized
500 Internal Server ErrorПосле перехода на WebSocket обычных HTTP-статусов для каждого сообщения нет.
Приложение самостоятельно проектирует ответы:
{
"type": "error",
"payload": {
"code": "message_validation_failed",
"message": "Текст сообщения обязателен"
}
}Ошибку установки соединения ещё можно вернуть как HTTP-ответ:
HTTP/1.1 401 UnauthorizedПосле успешного handshake ошибки передаются сообщениями или через закрытие соединения.
Состояние
HTTP API часто проектируется как stateless:
Каждый запрос содержит данные, необходимые для обработкиWebSocket-сервер обычно хранит состояние активного соединения:
{
userId: "user-42",
connectedAt: 1789660800000,
subscribedRooms: ["room-1", "room-2"]
}Это усложняет:
- масштабирование;
- балансировку;
- перезапуск сервера;
- восстановление соединения;
- управление подписками;
- освобождение ресурсов.
WebSocket, polling и Server-Sent Events
WebSocket — не единственный способ доставлять обновления в реальном времени.
Polling
Клиент периодически отправляет запрос:
setInterval(async () => {
const response = await fetch(
"/api/notifications",
);
const notifications =
await response.json();
renderNotifications(notifications);
}, 5000);Схема:
Клиент → Есть новые данные?
Сервер → Нет
Через 5 секунд:
Клиент → Есть новые данные?
Сервер → ДаPolling прост, но создаёт лишние запросы и задержку между появлением данных и получением.
Long Polling
Сервер удерживает HTTP-запрос до появления данных или тайм-аута:
Клиент → Запрос
Сервер ждёт событие
Сервер → Ответ с событием
Клиент → Сразу создаёт новый запросLong polling может работать через инфраструктуру, в которой WebSocket недоступен, но создаёт больше HTTP-операций.
Server-Sent Events
Server-Sent Events (SSE) позволяют серверу передавать текстовые события клиенту через длительное HTTP-соединение.
Клиент ← СерверSSE подходит, когда данные передаются преимущественно от сервера к браузеру:
- уведомления;
- прогресс операции;
- поток журналов;
- обновление статуса;
- изменение показателей.
WebSocket подходит, когда нужен активный двусторонний обмен:
Клиент ⇄ СерверСравнение:
| Свойство | Polling | SSE | WebSocket |
|---|---|---|---|
| Направление | Клиент запрашивает | Сервер → клиент | Оба направления |
| Соединение | Много запросов | Длительное HTTP | Длительный WebSocket |
| Автопереподключение | Реализуется вручную | Есть в браузерном API | Реализуется вручную или библиотекой |
| Бинарные данные | Через HTTP | Обычно текст | Поддерживаются |
| Простота | Высокая | Высокая | Ниже |
| Чаты | Возможны, но неудобны | Только часть обмена | Подходит |
Проектирование сообщений
WebSocket определяет транспорт, но не определяет бизнес-формат сообщений.
Удобно использовать единый envelope:
{
"type": "chat.message.send",
"id": "event-6c21b86d",
"timestamp": "2026-09-23T10:30:00.000Z",
"payload": {
"roomId": "room-42",
"text": "Привет!"
}
}Поля:
| Поле | Назначение |
|---|---|
type |
Тип события |
id |
Идентификатор сообщения |
timestamp |
Время создания |
payload |
Полезные данные |
requestId |
Идентификатор операции, если нужен ответ |
version |
Версия формата сообщения |
Ответ на команду:
{
"type": "chat.message.created",
"requestId": "event-6c21b86d",
"payload": {
"messageId": "message-815",
"roomId": "room-42",
"text": "Привет!"
}
}Ошибка:
{
"type": "error",
"requestId": "event-6c21b86d",
"payload": {
"code": "validation_failed",
"message": "Текст сообщения обязателен"
}
}Типы событий желательно называть последовательно:
chat.message.send
chat.message.created
chat.message.deleted
notification.created
notification.read
user.presence.changedКоманды и события
Полезно различать:
Команда — просьба выполнить действие
Событие — факт, который уже произошёлКоманда:
chat.message.sendСобытие:
chat.message.createdНе следует сообщать другим клиентам, что сообщение создано, пока сервер:
- не проверил данные;
- не проверил права;
- не сохранил сообщение;
- не получил подтверждение основной операции.
Версионирование сообщений
Формат может изменяться:
{
"version": 1,
"type": "notification.created",
"payload": {}
}Или версия включается в URL:
wss://api.example.com/ws/v1Изменения должны учитывать, что часть клиентов может оставаться подключённой со старой версией приложения.
Реализация WebSocket-сервера через ws
Пакет ws предоставляет реализацию низкоуровневого WebSocket для Node.js.
Установка:
npm install wsПростой сервер
import {
WebSocketServer,
WebSocket,
} from "ws";
const webSocketServer =
new WebSocketServer({
port: 8080,
});
webSocketServer.on(
"connection",
(socket, request) => {
console.log(
"Client connected",
request.socket.remoteAddress,
);
socket.send(
JSON.stringify({
type: "connection.ready",
payload: {
message:
"WebSocket connection established",
},
}),
);
socket.on("message", (rawData) => {
const text = rawData.toString();
console.log(
"Message received:",
text,
);
socket.send(
JSON.stringify({
type: "message.received",
payload: {
originalMessage: text,
},
}),
);
});
socket.on("close", (
code,
reason,
) => {
console.log(
"Client disconnected",
{
code,
reason: reason.toString(),
},
);
});
socket.on("error", (error) => {
console.error(
"WebSocket error",
error,
);
});
},
);Запуск:
node server.jsКлиент подключается к:
ws://localhost:8080Сервер вместе с HTTP
WebSocket можно подключить к существующему HTTP-серверу:
import http from "node:http";
import express from "express";
import {
WebSocketServer,
} from "ws";
const app = express();
app.get("/health/live", (
request,
response,
) => {
response.json({
status: "ok",
});
});
const httpServer =
http.createServer(app);
const webSocketServer =
new WebSocketServer({
server: httpServer,
path: "/ws",
});
webSocketServer.on(
"connection",
(socket) => {
socket.send(
JSON.stringify({
type: "connection.ready",
}),
);
},
);
httpServer.listen(8080, () => {
console.log(
"Server started on port 8080",
);
});Теперь приложение поддерживает:
http://localhost:8080/health/live
ws://localhost:8080/wsВ production:
https://api.example.com/health/live
wss://api.example.com/wsРазбор входящих сообщений
Клиент может отправить некорректный JSON или данные неожиданной структуры.
function parseMessage(rawData) {
let message;
try {
message = JSON.parse(
rawData.toString(),
);
} catch {
throw new Error(
"Некорректный JSON",
);
}
if (
!message ||
typeof message !== "object" ||
Array.isArray(message)
) {
throw new Error(
"Сообщение должно быть объектом",
);
}
if (
typeof message.type !== "string"
) {
throw new Error(
"Тип сообщения обязателен",
);
}
return message;
}Использование:
socket.on("message", async (rawData) => {
try {
const message =
parseMessage(rawData);
await handleMessage(
socket,
message,
);
} catch (error) {
socket.send(
JSON.stringify({
type: "error",
payload: {
code:
"invalid_message",
message: error.message,
},
}),
);
}
});Маршрутизация событий
const messageHandlers = new Map();
messageHandlers.set(
"chat.message.send",
handleChatMessage,
);
messageHandlers.set(
"notification.read",
handleNotificationRead,
);
async function handleMessage(
socket,
message,
) {
const handler =
messageHandlers.get(
message.type,
);
if (!handler) {
socket.send(
JSON.stringify({
type: "error",
requestId: message.id,
payload: {
code:
"unsupported_message_type",
message:
"Неизвестный тип сообщения",
},
}),
);
return;
}
await handler({
socket,
message,
});
}Такой подход лучше большого блока условий:
if (message.type === "...") {
// ...
} else if (message.type === "...") {
// ...
}Функция отправки JSON
import { WebSocket } from "ws";
function sendJson(socket, message) {
if (
socket.readyState !==
WebSocket.OPEN
) {
return false;
}
socket.send(
JSON.stringify(message),
);
return true;
}Использование:
sendJson(socket, {
type: "notification.created",
payload: {
id: "notification-42",
text: "Заказ отправлен",
},
});Перед отправкой следует проверять readyState, потому что соединение уже могло закрыться.
Рассылка сообщений
Рассылка всем клиентам
function broadcast(
webSocketServer,
message,
) {
const serializedMessage =
JSON.stringify(message);
for (
const client
of webSocketServer.clients
) {
if (
client.readyState ===
WebSocket.OPEN
) {
client.send(
serializedMessage,
);
}
}
}Использование:
broadcast(
webSocketServer,
{
type: "system.message",
payload: {
text:
"Сервис будет обновлён",
},
},
);Рассылать все события всем клиентам обычно не следует. Клиенты должны получать только данные, на которые у них есть право.
Подписка на комнаты
Комната объединяет клиентов, которым нужны одинаковые события:
room:chat-42
room:user-815
room:project-17Простое хранилище комнат:
const rooms = new Map();
function joinRoom(
socket,
roomId,
) {
let room = rooms.get(roomId);
if (!room) {
room = new Set();
rooms.set(roomId, room);
}
room.add(socket);
if (!socket.rooms) {
socket.rooms = new Set();
}
socket.rooms.add(roomId);
}Выход:
function leaveRoom(
socket,
roomId,
) {
const room = rooms.get(roomId);
if (!room) {
return;
}
room.delete(socket);
socket.rooms?.delete(roomId);
if (room.size === 0) {
rooms.delete(roomId);
}
}Рассылка комнате:
function broadcastToRoom(
roomId,
message,
) {
const room = rooms.get(roomId);
if (!room) {
return;
}
const serializedMessage =
JSON.stringify(message);
for (const socket of room) {
if (
socket.readyState ===
WebSocket.OPEN
) {
socket.send(
serializedMessage,
);
}
}
}Очистка после отключения:
function leaveAllRooms(socket) {
if (!socket.rooms) {
return;
}
for (const roomId of socket.rooms) {
leaveRoom(socket, roomId);
}
}
socket.on("close", () => {
leaveAllRooms(socket);
});Перед добавлением пользователя в комнату сервер обязан проверить право доступа:
const canJoin =
await chatAuthorization.canJoinRoom({
userId: socket.user.id,
roomId,
});
if (!canJoin) {
throw new Error(
"Недостаточно прав",
);
}
joinRoom(socket, roomId);Аутентификация WebSocket
Соединение нужно связать с пользователем.
Возможные варианты:
- серверная сессия в cookie;
- краткоживущий одноразовый ticket;
- токен в разрешённом механизме клиента;
- аутентификационное сообщение сразу после соединения;
- заголовок
Authorizationдля небраузерных клиентов.
Браузерный конструктор WebSocket не позволяет произвольно установить заголовок Authorization.
Нельзя написать:
new WebSocket(
"wss://api.example.com/ws",
{
headers: {
Authorization:
"Bearer token",
},
},
);Такой API не поддерживается стандартным браузерным WebSocket.
Аутентификация через cookie
Браузер автоматически отправляет подходящие cookie во время handshake.
const socket = new WebSocket(
"wss://api.example.com/ws",
);Сервер получает cookie в HTTP-запросе обновления протокола.
Концептуальная проверка:
webSocketServer.on(
"connection",
async (socket, request) => {
const sessionId =
extractSessionId(
request.headers.cookie,
);
const session =
await sessionRepository.findById(
sessionId,
);
if (!session) {
socket.close(
1008,
"Authentication required",
);
return;
}
socket.user = {
id: session.userId,
};
},
);Если используются cookie, необходимо учитывать:
Secure;HttpOnly;SameSite;- проверку
Origin; - срок жизни сессии;
- отзыв сессии;
- CSRF-подобный риск нежелательного межсайтового подключения.
Проверка Origin
WebSocket не использует обычную CORS-проверку так же, как fetch(). Сервер должен самостоятельно проверять Origin.
const allowedOrigins = new Set([
"https://app.example.com",
]);
function isAllowedOrigin(request) {
const origin =
request.headers.origin;
return (
typeof origin === "string" &&
allowedOrigins.has(origin)
);
}Если origin не разрешён:
if (!isAllowedOrigin(request)) {
socket.close(
1008,
"Origin is not allowed",
);
return;
}Для более раннего отказа проверку можно выполнить на стадии HTTP upgrade.
Небраузерные клиенты могут не отправлять Origin, поэтому политика должна соответствовать типам разрешённых клиентов.
Проверка Origin не заменяет аутентификацию и авторизацию.
Токен в URL
Технически можно передать токен:
wss://api.example.com/ws?token=secretНо долгоживущий токен в URL нежелателен. URL может попасть:
- в журналы reverse proxy;
- в аналитику;
- в диагностические сообщения;
- в историю мониторинга;
- в трассировку.
Безопаснее использовать короткоживущий одноразовый ticket:
1. Клиент получает ticket через защищённый HTTP endpoint.
2. Ticket действует несколько секунд.
3. Клиент открывает WebSocket с ticket.
4. Сервер использует ticket только один раз.Получение ticket:
const response = await fetch(
"/api/ws-ticket",
{
method: "POST",
credentials: "include",
},
);
const { ticket } =
await response.json();
const socket = new WebSocket(
`wss://api.example.com/ws?ticket=${encodeURIComponent(
ticket,
)}`,
);Сервер должен:
- ограничить срок жизни;
- сделать ticket случайным;
- связать его с пользователем;
- удалить после первого использования;
- не считать его обычным access-токеном.
Аутентификационное сообщение
Клиент подключается и сразу отправляет токен:
socket.addEventListener(
"open",
() => {
socket.send(
JSON.stringify({
type: "auth.authenticate",
payload: {
accessToken,
},
}),
);
},
);Пока аутентификация не завершена, сервер не должен обрабатывать остальные команды:
if (
!socket.user &&
message.type !==
"auth.authenticate"
) {
socket.close(
1008,
"Authentication required",
);
return;
}Недостаток такого подхода — соединение сначала устанавливается без аутентификации. Нужно ограничивать время ожидания и объём данных.
const authenticationTimeout =
setTimeout(() => {
if (!socket.user) {
socket.close(
1008,
"Authentication timeout",
);
}
}, 5000);
authenticationTimeout.unref?.();После успешной проверки:
socket.user = verifiedUser;
clearTimeout(
authenticationTimeout,
);Авторизация каждого действия
Аутентифицированное соединение не означает, что пользователь может выполнять любые операции.
Для каждого сообщения проверяются:
- разрешение на действие;
- доступ к комнате;
- принадлежность ресурса;
- состояние объекта;
- ограничения пользователя.
async function handleChatMessage({
socket,
message,
}) {
const { roomId, text } =
validateChatMessage(
message.payload,
);
const canWrite =
await chatAuthorization
.canSendMessage({
userId: socket.user.id,
roomId,
});
if (!canWrite) {
sendJson(socket, {
type: "error",
requestId: message.id,
payload: {
code: "forbidden",
message:
"Недостаточно прав",
},
});
return;
}
// Сохранение сообщения
}Нельзя доверять userId, присланному клиентом:
{
"userId": "admin",
"text": "Сообщение"
}Автор сообщения определяется по аутентифицированному соединению:
const authorId =
socket.user.id;Heartbeat: Ping и Pong
Соединение может перестать работать без корректного события close:
- устройство потеряло сеть;
- процесс завершился;
- NAT удалил состояние соединения;
- прокси закрыл неактивное соединение;
- ноутбук перешёл в спящий режим.
Поэтому сервер периодически проверяет соединения.
function heartbeat() {
this.isAlive = true;
}При подключении:
webSocketServer.on(
"connection",
(socket) => {
socket.isAlive = true;
socket.on(
"pong",
heartbeat,
);
},
);Периодическая проверка:
const heartbeatInterval =
setInterval(() => {
for (
const socket
of webSocketServer.clients
) {
if (!socket.isAlive) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30_000);
heartbeatInterval.unref?.();При остановке сервера:
clearInterval(
heartbeatInterval,
);Браузерный API не предоставляет JavaScript-коду низкоуровневые методы ping() и событие pong. Браузер обрабатывает протокольные ping/pong на своём уровне.
Если приложению нужен собственный heartbeat, можно использовать сообщения:
{
"type": "heartbeat"
}Ответ:
{
"type": "heartbeat.ack"
}Закрытие соединения
Клиент может закрыть соединение:
socket.close(
1000,
"Page closed",
);Код 1000 означает нормальное завершение.
Распространённые коды:
| Код | Значение |
|---|---|
1000 |
Нормальное закрытие |
1001 |
Сторона покидает соединение |
1002 |
Ошибка протокола |
1003 |
Неподдерживаемый тип данных |
1008 |
Нарушение политики |
1009 |
Сообщение слишком большое |
1011 |
Внутренняя ошибка сервера |
Некоторые коды зарезервированы и не могут передаваться приложением как обычный close code.
Сервер может закрыть соединение при:
- ошибке аутентификации;
- нарушении протокола;
- превышении лимита;
- слишком большом сообщении;
- завершении работы сервера;
- истечении сессии.
socket.close(
1008,
"Session expired",
);Причина закрытия должна быть короткой и не содержать секреты или внутренние детали.
Ограничение размера сообщений
Сервер должен ограничивать размер входящих сообщений.
const webSocketServer =
new WebSocketServer({
server: httpServer,
path: "/ws",
maxPayload: 64 * 1024,
});В этом примере максимальный размер равен 64 KiB.
Без ограничения клиент может отправлять большие сообщения и расходовать:
- память;
- CPU на разбор JSON;
- сетевую пропускную способность;
- время event loop.
Для файлов обычно лучше использовать отдельный HTTP upload, а через WebSocket передавать статус и метаданные.
Rate limiting сообщений
HTTP rate limiter не контролирует сообщения после WebSocket handshake. Для соединений нужен отдельный лимит.
Простой token bucket:
function createMessageRateLimiter({
capacity,
refillPerSecond,
}) {
let tokens = capacity;
let lastRefillAt = Date.now();
return function consume() {
const now = Date.now();
const elapsedSeconds =
(now - lastRefillAt) / 1000;
tokens = Math.min(
capacity,
tokens +
elapsedSeconds *
refillPerSecond,
);
lastRefillAt = now;
if (tokens < 1) {
return false;
}
tokens -= 1;
return true;
};
}Для каждого соединения:
socket.consumeMessageToken =
createMessageRateLimiter({
capacity: 20,
refillPerSecond: 5,
});Проверка:
socket.on("message", (rawData) => {
if (
!socket.consumeMessageToken()
) {
sendJson(socket, {
type: "error",
payload: {
code:
"rate_limit_exceeded",
message:
"Слишком много сообщений",
},
});
return;
}
// Обработка сообщения
});В распределённой системе лимиты на пользователя могут храниться централизованно, например в Redis.
Следует ограничивать:
- число соединений с одного пользователя;
- число попыток подключения;
- число сообщений в секунду;
- объём сообщений;
- частоту дорогих операций;
- число подписок на комнаты.
Backpressure
Если сервер отправляет данные быстрее, чем клиент успевает принимать, внутренний буфер начинает расти.
Это называется backpressure.
В ws можно проверять:
socket.bufferedAmountПример:
const MAX_BUFFERED_BYTES =
1024 * 1024;
function safeSend(
socket,
message,
) {
if (
socket.readyState !==
WebSocket.OPEN
) {
return false;
}
if (
socket.bufferedAmount >
MAX_BUFFERED_BYTES
) {
socket.close(
1008,
"Client is too slow",
);
return false;
}
socket.send(
JSON.stringify(message),
);
return true;
}Вместо немедленного закрытия можно:
- пропускать неважные события;
- объединять обновления;
- отправлять только последнее состояние;
- временно приостанавливать поток;
- ограничивать размер очереди.
Для уведомлений о текущем состоянии часто нет смысла отправлять каждое промежуточное изменение:
status = 10%
status = 11%
status = 12%
...Можно отправлять обновления реже или заменять старое неотправленное состояние новым.
Клиентская работа с WebSocket
Браузер предоставляет встроенный класс WebSocket.
const socket = new WebSocket(
"wss://api.example.com/ws",
);Событие open
socket.addEventListener(
"open",
() => {
console.log(
"WebSocket connected",
);
socket.send(
JSON.stringify({
type: "chat.room.join",
payload: {
roomId: "room-42",
},
}),
);
},
);Отправлять сообщения следует после открытия соединения.
Событие message
socket.addEventListener(
"message",
(event) => {
try {
const message =
JSON.parse(event.data);
handleServerMessage(
message,
);
} catch (error) {
console.error(
"Invalid WebSocket message",
error,
);
}
},
);Маршрутизация:
function handleServerMessage(message) {
switch (message.type) {
case "chat.message.created":
renderChatMessage(
message.payload,
);
break;
case "notification.created":
showNotification(
message.payload,
);
break;
case "user.presence.changed":
updatePresence(
message.payload,
);
break;
case "error":
showError(
message.payload.message,
);
break;
default:
console.warn(
"Unknown message type",
message.type,
);
}
}Событие error
socket.addEventListener(
"error",
(event) => {
console.error(
"WebSocket error",
event,
);
},
);Браузер намеренно предоставляет ограниченные подробности сетевой ошибки. Основную диагностику следует выполнять через:
- событие
close; - серверные логи;
- Network в DevTools;
- метрики подключений;
- идентификаторы соединений.
Событие close
socket.addEventListener(
"close",
(event) => {
console.log(
"WebSocket closed",
{
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
},
);
},
);Поля:
| Поле | Назначение |
|---|---|
code |
Код закрытия |
reason |
Краткая причина |
wasClean |
Было ли соединение закрыто корректно |
Состояния соединения
WebSocket.CONNECTING
WebSocket.OPEN
WebSocket.CLOSING
WebSocket.CLOSEDПроверка:
if (
socket.readyState ===
WebSocket.OPEN
) {
socket.send(message);
}Переподключение
Стандартный браузерный WebSocket не переподключается автоматически.
Причины разрыва:
- временная потеря сети;
- перезапуск сервера;
- переключение мобильной сети;
- закрытие соединения proxy;
- переход устройства в спящий режим;
- развёртывание новой версии.
Простой клиент с повторным подключением:
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.socket = null;
this.retryAttempt = 0;
this.closedManually = false;
}
connect() {
this.socket =
new WebSocket(this.url);
this.socket.addEventListener(
"open",
() => {
this.retryAttempt = 0;
console.log(
"WebSocket connected",
);
},
);
this.socket.addEventListener(
"message",
(event) => {
this.handleMessage(event);
},
);
this.socket.addEventListener(
"close",
() => {
if (!this.closedManually) {
this.scheduleReconnect();
}
},
);
}
scheduleReconnect() {
const baseDelay =
Math.min(
1000 *
2 ** this.retryAttempt,
30_000,
);
const jitter =
Math.random() * 1000;
const delay =
baseDelay + jitter;
this.retryAttempt += 1;
setTimeout(() => {
this.connect();
}, delay);
}
handleMessage(event) {
try {
const message =
JSON.parse(event.data);
console.log(message);
} catch (error) {
console.error(
"Invalid message",
error,
);
}
}
send(message) {
if (
this.socket?.readyState !==
WebSocket.OPEN
) {
return false;
}
this.socket.send(
JSON.stringify(message),
);
return true;
}
close() {
this.closedManually = true;
this.socket?.close(
1000,
"Client shutdown",
);
}
}Использование:
const realtime =
new ReconnectingWebSocket(
"wss://api.example.com/ws",
);
realtime.connect();Exponential backoff
Нельзя переподключаться без задержки:
socket.onclose = () => {
connect();
};При падении сервера тысячи клиентов начнут непрерывно подключаться и увеличат нагрузку.
Лучше применять:
1 секунда
2 секунды
4 секунды
8 секунд
...
до максимального интервалаИ добавлять случайное отклонение — jitter.
Это распределяет подключения клиентов по времени.
Восстановление состояния после переподключения
Новое соединение не знает состояние старого.
Клиенту может потребоваться повторно:
- аутентифицироваться;
- подписаться на комнаты;
- передать состояние присутствия;
- запросить пропущенные события;
- синхронизировать данные.
socket.addEventListener(
"open",
() => {
for (
const roomId
of subscribedRooms
) {
socket.send(
JSON.stringify({
type:
"chat.room.join",
payload: {
roomId,
},
}),
);
}
},
);WebSocket сам по себе не гарантирует доставку событий, произошедших во время отключения.
Для восстановления можно использовать:
- загрузку истории через HTTP;
- номер последнего события;
- курсор;
- event ID;
- журнал событий;
- очередь с сохранением;
- повторную синхронизацию текущего состояния.
Идентификатор последнего события
Сервер отправляет последовательность:
{
"type": "chat.message.created",
"eventId": 1542,
"payload": {}
}Клиент сохраняет:
let lastEventId = 1542;После переподключения:
{
"type": "connection.resume",
"payload": {
"lastEventId": 1542
}
}Сервер может отправить пропущенные события, если они сохраняются.
Если история событий недоступна, клиент должен повторно загрузить актуальное состояние через HTTP.
Подтверждение доставки
Вызов:
socket.send(message);означает только передачу данных WebSocket-реализации. Он не подтверждает, что бизнес-операция успешно выполнена сервером.
Для команды нужен ответ:
Клиент:
{
"type": "chat.message.send",
"id": "request-42",
"payload": {
"roomId": "room-7",
"text": "Привет!"
}
}Сервер:
{
"type": "chat.message.created",
"requestId": "request-42",
"payload": {
"messageId": "message-815"
}
}Клиент может хранить ожидающие операции:
const pendingRequests =
new Map();
function sendRequest(
socket,
type,
payload,
timeoutMs = 5000,
) {
const id = crypto.randomUUID();
return new Promise(
(resolve, reject) => {
const timeout = setTimeout(
() => {
pendingRequests.delete(id);
reject(
new Error(
"WebSocket request timed out",
),
);
},
timeoutMs,
);
pendingRequests.set(id, {
resolve,
reject,
timeout,
});
socket.send(
JSON.stringify({
id,
type,
payload,
}),
);
},
);
}Обработка ответа:
function handleMessage(message) {
if (
message.requestId &&
pendingRequests.has(
message.requestId,
)
) {
const pending =
pendingRequests.get(
message.requestId,
);
clearTimeout(
pending.timeout,
);
pendingRequests.delete(
message.requestId,
);
if (message.type === "error") {
pending.reject(
new Error(
message.payload.message,
),
);
} else {
pending.resolve(
message.payload,
);
}
return;
}
handleEvent(message);
}Идемпотентность сообщений
При переподключении клиент может не знать, обработал ли сервер последнюю команду.
Пример:
Клиент отправил сообщение
Сервер сохранил сообщение
Соединение оборвалось до подтверждения
Клиент отправил сообщение повторноБез защиты в чате появятся дубликаты.
Клиент может передавать уникальный clientMessageId:
{
"type": "chat.message.send",
"id": "request-42",
"payload": {
"clientMessageId": "client-message-99",
"roomId": "room-7",
"text": "Привет!"
}
}Сервер сохраняет уникальный идентификатор и не создаёт второе сообщение при повторе:
const existingMessage =
await messageRepository
.findByClientMessageId({
userId: socket.user.id,
clientMessageId:
payload.clientMessageId,
});
if (existingMessage) {
sendMessageCreated(
socket,
message.id,
existingMessage,
);
return;
}Уникальность желательно обеспечить и на уровне базы данных.
Реализация через Socket.IO
Socket.IO — библиотека для событийного обмена в реальном времени.
Socket.IO не является просто удобной оболочкой над стандартным WebSocket. У него собственный протокол поверх транспортов.
Поэтому стандартный WebSocket-клиент не может напрямую подключиться к Socket.IO-серверу:
// Не является Socket.IO-клиентом
new WebSocket(
"wss://example.com/socket.io/",
);Для Socket.IO используется специальный клиент:
import { io } from "socket.io-client";Возможности Socket.IO
Socket.IO предоставляет:
- автоматическое переподключение;
- событийный API;
- rooms;
- namespaces;
- acknowledgements;
- heartbeat;
- middleware;
- fallback-транспорт в поддерживаемых конфигурациях;
- адаптеры для масштабирования.
ws предоставляет более низкоуровневый WebSocket и требует самостоятельно реализовать большую часть этих возможностей.
Установка
Сервер:
npm install socket.ioКлиент:
npm install socket.io-clientSocket.IO-сервер
import http from "node:http";
import express from "express";
import {
Server,
} from "socket.io";
const app = express();
const httpServer =
http.createServer(app);
const io = new Server(
httpServer,
{
cors: {
origin:
"https://app.example.com",
credentials: true,
},
},
);
io.on(
"connection",
(socket) => {
console.log(
"Client connected",
socket.id,
);
socket.emit(
"connection:ready",
{
socketId: socket.id,
},
);
socket.on(
"chat:message",
async (
payload,
acknowledge,
) => {
try {
const message =
await saveMessage(
payload,
);
io
.to(
`room:${message.roomId}`,
)
.emit(
"chat:message-created",
message,
);
acknowledge?.({
ok: true,
messageId: message.id,
});
} catch {
acknowledge?.({
ok: false,
error:
"message_creation_failed",
});
}
},
);
socket.on(
"disconnect",
(reason) => {
console.log(
"Client disconnected",
{
socketId: socket.id,
reason,
},
);
},
);
},
);
httpServer.listen(8080);Socket.IO-клиент
import { io } from "socket.io-client";
const socket = io(
"https://api.example.com",
{
withCredentials: true,
},
);
socket.on("connect", () => {
console.log(
"Connected",
socket.id,
);
});
socket.on(
"chat:message-created",
(message) => {
renderChatMessage(message);
},
);
socket.on(
"disconnect",
(reason) => {
console.log(
"Disconnected",
reason,
);
},
);Отправка с acknowledgement:
socket.emit(
"chat:message",
{
roomId: "room-42",
text: "Привет!",
},
(result) => {
if (!result.ok) {
showError(
"Не удалось отправить сообщение",
);
return;
}
console.log(
"Message created",
result.messageId,
);
},
);Аутентификация Socket.IO
Клиент:
const socket = io(
"https://api.example.com",
{
auth: {
token: accessToken,
},
},
);Сервер:
io.use(
async (socket, next) => {
try {
const token =
socket.handshake.auth.token;
const user =
await tokenService.verify(
token,
);
socket.data.user = user;
next();
} catch {
next(
new Error(
"Authentication failed",
),
);
}
},
);Токен не следует журналировать.
Если токен истекает, необходимо определить:
- закрывать ли соединение;
- запрашивать ли повторную аутентификацию;
- обновлять ли credentials при переподключении;
- как отзывать доступ немедленно.
Rooms в Socket.IO
Подключение к комнате:
socket.on(
"chat:join",
async (
{ roomId },
acknowledge,
) => {
const canJoin =
await chatAuthorization
.canJoinRoom({
userId:
socket.data.user.id,
roomId,
});
if (!canJoin) {
acknowledge?.({
ok: false,
error: "forbidden",
});
return;
}
await socket.join(
`room:${roomId}`,
);
acknowledge?.({
ok: true,
});
},
);Отправка комнате:
io
.to(`room:${roomId}`)
.emit(
"chat:message-created",
message,
);Всем в комнате, кроме отправителя:
socket
.to(`room:${roomId}`)
.emit(
"chat:user-typing",
{
userId:
socket.data.user.id,
},
);Namespace
Namespace создаёт отдельный логический канал:
const adminNamespace =
io.of("/admin");
adminNamespace.use(
authenticateAdmin,
);
adminNamespace.on(
"connection",
(socket) => {
// Только административные соединения
},
);Клиент:
const adminSocket = io(
"https://api.example.com/admin",
);Namespaces полезны для логического разделения, но не заменяют проверку прав.
ws или Socket.IO
| Критерий | ws |
Socket.IO |
|---|---|---|
| Стандартный WebSocket | Да | Использует собственный протокол |
| Низкоуровневый контроль | Высокий | Ниже |
| Автопереподключение | Реализуется вручную | Встроено |
| Rooms | Реализуются вручную | Встроены |
| Acknowledgements | Реализуются вручную | Встроены |
| Размер протокола | Меньше | Больше |
| Совместимость со стандартным клиентом | Да | Нужен Socket.IO-клиент |
| Скорость начала разработки | Ниже | Обычно выше |
| Гибкость протокола | Высокая | Событийная модель библиотеки |
ws подходит, если:
- нужен стандартный WebSocket;
- важен минимальный протокол;
- требуется контроль над форматом;
- есть небраузерные клиенты со стандартной поддержкой WS;
- команда готова реализовать reconnect, rooms и acknowledgements.
Socket.IO подходит, если:
- нужен быстрый событийный API;
- важны rooms;
- требуется автоматическое переподключение;
- клиент и сервер могут использовать Socket.IO;
- допустим собственный протокол библиотеки.
Практическое применение: чат
Типичная архитектура чата:
HTTP:
GET /api/rooms/:roomId/messages
POST /api/attachments
WebSocket:
chat.room.join
chat.message.send
chat.message.created
chat.message.updated
chat.message.deleted
chat.user.typing
user.presence.changedИсторию сообщений удобно загружать через HTTP:
const response = await fetch(
`/api/rooms/${roomId}/messages?cursor=${cursor}`,
);
const history =
await response.json();Новые сообщения доставляются через WebSocket.
Отправка сообщения
Клиент:
function sendChatMessage({
roomId,
text,
}) {
const message = {
id: crypto.randomUUID(),
type: "chat.message.send",
payload: {
clientMessageId:
crypto.randomUUID(),
roomId,
text,
},
};
socket.send(
JSON.stringify(message),
);
}Сервер:
async function handleChatMessage({
socket,
message,
}) {
const input =
validateChatMessage(
message.payload,
);
const canWrite =
await chatAuthorization
.canSendMessage({
userId: socket.user.id,
roomId: input.roomId,
});
if (!canWrite) {
sendJson(socket, {
type: "error",
requestId: message.id,
payload: {
code: "forbidden",
message:
"Отправка сообщения запрещена",
},
});
return;
}
const savedMessage =
await messageRepository.create({
clientMessageId:
input.clientMessageId,
roomId: input.roomId,
authorId: socket.user.id,
text: input.text,
});
broadcastToRoom(
input.roomId,
{
type:
"chat.message.created",
requestId: message.id,
payload: {
id: savedMessage.id,
clientMessageId:
savedMessage
.clientMessageId,
roomId:
savedMessage.roomId,
authorId:
savedMessage.authorId,
text: savedMessage.text,
createdAt:
savedMessage.createdAt,
},
},
);
}Валидация сообщения
function validateChatMessage(
payload,
) {
if (
!payload ||
typeof payload !== "object"
) {
throw new Error(
"Некорректные данные",
);
}
if (
typeof payload.roomId !==
"string" ||
payload.roomId.length > 100
) {
throw new Error(
"Некорректная комната",
);
}
if (
typeof payload
.clientMessageId !== "string" ||
payload
.clientMessageId.length > 100
) {
throw new Error(
"Некорректный идентификатор сообщения",
);
}
if (
typeof payload.text !==
"string"
) {
throw new Error(
"Текст обязателен",
);
}
const text =
payload.text.trim();
if (
text.length < 1 ||
text.length > 4000
) {
throw new Error(
"Текст должен содержать от 1 до 4000 символов",
);
}
return {
roomId: payload.roomId,
clientMessageId:
payload.clientMessageId,
text,
};
}На клиенте сообщение следует выводить через textContent:
const messageElement =
document.createElement("p");
messageElement.textContent =
message.text;Нежелательно:
messageElement.innerHTML =
message.text;Иначе пользовательское сообщение может стать источником XSS.
Статус набора текста
Клиент:
let typingTimeout;
messageInput.addEventListener(
"input",
() => {
socket.send(
JSON.stringify({
type: "chat.typing.start",
payload: {
roomId,
},
}),
);
clearTimeout(
typingTimeout,
);
typingTimeout = setTimeout(
() => {
socket.send(
JSON.stringify({
type:
"chat.typing.stop",
payload: {
roomId,
},
}),
);
},
1500,
);
},
);Событие набора текста обычно:
- не сохраняется в базе;
- имеет короткий срок жизни;
- не требует гарантированной доставки;
- ограничивается по частоте;
- отправляется другим участникам комнаты.
Сервер:
socket
.to(`room:${roomId}`)
.emit(
"chat:typing",
{
userId:
socket.data.user.id,
isTyping: true,
},
);Presence
Presence показывает состояние пользователя:
online
offline
awayНельзя считать пользователя offline сразу после закрытия одного соединения. Один пользователь может открыть:
- несколько вкладок;
- браузер и мобильное приложение;
- несколько устройств.
Полезно учитывать число активных соединений пользователя:
const userConnections =
new Map();
function addUserConnection(
userId,
socket,
) {
let connections =
userConnections.get(userId);
if (!connections) {
connections = new Set();
userConnections.set(
userId,
connections,
);
}
connections.add(socket);
return connections.size;
}
function removeUserConnection(
userId,
socket,
) {
const connections =
userConnections.get(userId);
if (!connections) {
return 0;
}
connections.delete(socket);
if (connections.size === 0) {
userConnections.delete(userId);
return 0;
}
return connections.size;
}Пользователь становится offline, когда закрыто последнее соединение.
В распределённой системе локальной Map недостаточно. Состояние присутствия нужно координировать между экземплярами, например через Redis с TTL и событиями.
Практическое применение: real-time уведомления
Уведомление обычно возникает в результате операции:
Заказ отправлен
Платёж подтверждён
Пользователь получил сообщение
Отчёт сформирован
Администратор изменил статусСхема:
Бизнес-операция
↓
Сохранение уведомления
↓
Публикация события
↓
WebSocket Gateway
↓
Активные соединения пользователяПерсональная комната пользователя
После аутентификации соединение добавляется в комнату:
const userRoom =
`user:${socket.data.user.id}`;
await socket.join(userRoom);Отправка уведомления:
io
.to(`user:${userId}`)
.emit(
"notification:created",
{
id: notification.id,
type: notification.type,
text: notification.text,
createdAt:
notification.createdAt,
},
);Пользователь получит уведомление во всех своих активных вкладках и устройствах.
Сначала сохранить, потом отправить
Надёжнее сначала записать уведомление:
const notification =
await notificationRepository.create({
userId,
type: "order_shipped",
text: "Заказ отправлен",
});И только затем отправить его подключённому клиенту:
io
.to(`user:${userId}`)
.emit(
"notification:created",
notification,
);Если пользователь offline, уведомление останется в базе и будет доступно через HTTP:
GET /api/notifications?unread=trueWebSocket ускоряет доставку, но не должен быть единственным хранилищем уведомления.
Прочтение уведомления
Клиент отправляет:
{
"type": "notification.read",
"id": "request-42",
"payload": {
"notificationId": "notification-815"
}
}Сервер проверяет владельца:
const notification =
await notificationRepository.findById(
notificationId,
);
if (
!notification ||
notification.userId !==
socket.user.id
) {
sendJson(socket, {
type: "error",
requestId: message.id,
payload: {
code: "not_found",
message:
"Уведомление не найдено",
},
});
return;
}После обновления событие можно отправить всем соединениям пользователя:
broadcastToUser(
socket.user.id,
{
type:
"notification.updated",
payload: {
id: notification.id,
readAt:
new Date().toISOString(),
},
},
);Так прочитанное уведомление обновится во всех вкладках.
Масштабирование WebSocket
При одном экземпляре сервера соединения можно хранить в памяти:
WebSocket Server
├── Client A
├── Client B
└── Client CПри нескольких экземплярах:
Load Balancer
├── Server 1 → Client A
├── Server 2 → Client B
└── Server 3 → Client CЕсли Server 1 создаёт событие для Client B, он не может отправить его через локальный список соединений Server 2.
Нужен общий канал событий:
Server 1
↓ publish
Redis / message broker
↓
Server 2
↓
Client BRedis Pub/Sub для нескольких WS-серверов
Каждый сервер подписывается на канал:
await subscriber.subscribe(
"realtime.events",
(rawMessage) => {
const event =
JSON.parse(rawMessage);
deliverToLocalConnections(
event,
);
},
);Публикация:
await publisher.publish(
"realtime.events",
JSON.stringify({
userId: "user-42",
type:
"notification.created",
payload: notification,
}),
);Каждый экземпляр проверяет, есть ли у него соединения нужного пользователя.
Redis Pub/Sub не гарантирует доставку отключённому серверу. Для real-time-сигнала это может быть допустимо, если исходные данные сохранены и клиент может синхронизироваться после переподключения.
Socket.IO Redis Adapter
Для нескольких Socket.IO-серверов можно использовать адаптер, передающий события через Redis.
Концептуальная схема:
Socket.IO Server 1
↓
Redis adapter
↑
Socket.IO Server 2После настройки:
io
.to(`user:${userId}`)
.emit(
"notification:created",
notification,
);может доставить событие соединению на другом экземпляре.
Адаптер обеспечивает межсерверную рассылку, но не превращает временное событие в надёжно сохранённое сообщение. Критичные данные всё равно должны храниться отдельно.
Sticky sessions
Балансировщик может направлять одно соединение к одному серверу на всё время его жизни. Для WebSocket это естественно: установленное соединение остаётся привязано к экземпляру.
При некоторых fallback-механизмах и библиотечных протоколах может дополнительно потребоваться sticky session.
Даже при sticky sessions общий брокер всё равно нужен, если события должны доставляться клиентам на разных экземплярах.
Перезапуск сервера
При развёртывании сервер должен:
- перестать принимать новые соединения;
- сообщить балансировщику о неготовности;
- по возможности закрыть соединения корректно;
- дать клиентам переподключиться;
- завершить процесс через ограниченное время.
async function shutdown() {
for (
const socket
of webSocketServer.clients
) {
socket.close(
1001,
"Server restarting",
);
}
webSocketServer.close(() => {
httpServer.close(() => {
process.exit(0);
});
});
}Клиенты должны ожидать временные отключения и корректно восстанавливать состояние.
Proxy и WebSocket
Reverse proxy должен поддерживать protocol upgrade.
Концептуальная конфигурация Nginx:
location /ws {
proxy_pass http://application;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 60s;
}Без заголовков Upgrade и Connection handshake может не пройти.
Также необходимо учитывать:
- idle timeout;
- максимальную длительность соединения;
- размер сообщений;
- TLS termination;
- балансировку;
- readiness сервера;
- лимит соединений.
Heartbeat должен происходить чаще, чем idle timeout промежуточной инфраструктуры.
Безопасность WebSocket
WebSocket-соединение является длительным каналом доступа, поэтому требует тех же серверных проверок, что и HTTP API.
Основные меры:
- использовать
wss://; - проверять
Origin; - аутентифицировать соединение;
- авторизовывать каждое действие;
- ограничивать размер сообщений;
- валидировать структуру и типы;
- ограничивать частоту событий;
- ограничивать число соединений;
- не доверять
userIdот клиента; - не выводить сообщения через небезопасный HTML;
- регулярно проверять актуальность сессии;
- корректно отзывать соединения;
- не помещать долгоживущие секреты в URL;
- не журналировать токены и полные сообщения с чувствительными данными.
Истечение и отзыв доступа
Если токен был действителен при подключении, это не означает, что соединение должно работать бесконечно.
Возможные подходы:
- закрывать соединение по окончании срока токена;
- периодически проверять серверную сессию;
- отправлять событие повторной аутентификации;
- отключать соединения при отзыве сессии;
- публиковать событие
session.revoked; - ограничивать максимальную длительность соединения.
Пример:
const expiresInMs =
token.expiresAt - Date.now();
const expirationTimer =
setTimeout(() => {
socket.close(
1008,
"Session expired",
);
}, expiresInMs);
expirationTimer.unref?.();
socket.on("close", () => {
clearTimeout(
expirationTimer,
);
});Защита от XSS в чате
Небезопасно:
messagesContainer.innerHTML += `
<p>${message.text}</p>
`;Безопасно:
const paragraph =
document.createElement("p");
paragraph.textContent =
message.text;
messagesContainer.append(
paragraph,
);Если приложение разрешает форматированный HTML, нужен специализированный HTML-санитайзер с allowlist.
Защита от повторной отправки
Злоумышленник может повторно отправлять одно сообщение.
Полезны:
- уникальный
messageId; - идемпотентность;
- проверка временной метки;
- дедупликация;
- rate limiting;
- серверная проверка состояния операции.
Логи и метрики WebSocket
Полезные логи:
Соединение установлено
Соединение закрыто
Аутентификация отклонена
Подписка на комнату запрещена
Получено некорректное сообщение
Превышен лимит
Медленный клиент отключён
Ошибка обработки событияСтруктурированный лог:
logger.info(
{
connectionId:
socket.connectionId,
userId: socket.user?.id,
origin:
request.headers.origin,
},
"WebSocket connected",
);Не следует записывать:
- access-токен;
- session ID;
- cookie;
- полный текст приватного сообщения без необходимости;
- персональные данные;
- секретные query-параметры.
Метрики
Полезные метрики:
websocket_connections_active
websocket_connections_total
websocket_connection_errors_total
websocket_messages_received_total
websocket_messages_sent_total
websocket_message_errors_total
websocket_message_duration_seconds
websocket_reconnections_total
websocket_disconnections_total
websocket_buffered_bytesПодходящие labels:
event_type
result
close_code
serviceНежелательные labels:
userId
connectionId
messageId
roomId с неограниченным числом значенийУникальные идентификаторы создают высокую cardinality. Их следует помещать в логи и трейсы.
Health-check
Обычный HTTP health-check может использоваться и для приложения с WebSocket:
app.get(
"/health/live",
(request, response) => {
response.json({
status: "ok",
});
},
);Readiness должна учитывать способность принимать новые соединения:
app.get(
"/health/ready",
(request, response) => {
if (shuttingDown) {
return response
.status(503)
.json({
status: "not_ready",
});
}
return response.json({
status: "ready",
});
},
);Не следует устанавливать отдельное WebSocket-соединение для каждого health-check, если достаточно проверить HTTP endpoint и внутреннее состояние сервера.
Полный минимальный пример на ws
import http from "node:http";
import express from "express";
import {
WebSocket,
WebSocketServer,
} from "ws";
import { randomUUID } from "node:crypto";
const app = express();
app.get(
"/health/live",
(request, response) => {
response.json({
status: "ok",
});
},
);
const httpServer =
http.createServer(app);
const webSocketServer =
new WebSocketServer({
server: httpServer,
path: "/ws",
maxPayload: 64 * 1024,
});
const allowedOrigins = new Set([
"http://localhost:3000",
"https://app.example.com",
]);
function sendJson(
socket,
message,
) {
if (
socket.readyState !==
WebSocket.OPEN
) {
return false;
}
socket.send(
JSON.stringify(message),
);
return true;
}
function parseMessage(rawData) {
let message;
try {
message = JSON.parse(
rawData.toString(),
);
} catch {
throw new Error(
"Некорректный JSON",
);
}
if (
!message ||
typeof message !== "object" ||
typeof message.type !== "string"
) {
throw new Error(
"Некорректная структура сообщения",
);
}
return message;
}
webSocketServer.on(
"connection",
async (socket, request) => {
const origin =
request.headers.origin;
if (
!origin ||
!allowedOrigins.has(origin)
) {
socket.close(
1008,
"Origin is not allowed",
);
return;
}
const user =
await authenticateConnection(
request,
);
if (!user) {
socket.close(
1008,
"Authentication required",
);
return;
}
socket.connectionId =
randomUUID();
socket.user = user;
socket.isAlive = true;
socket.on("pong", () => {
socket.isAlive = true;
});
sendJson(socket, {
type: "connection.ready",
payload: {
connectionId:
socket.connectionId,
userId: user.id,
},
});
socket.on(
"message",
async (rawData) => {
try {
const message =
parseMessage(rawData);
await handleMessage({
socket,
message,
});
} catch (error) {
sendJson(socket, {
type: "error",
payload: {
code:
"invalid_message",
message:
error.message,
},
});
}
},
);
socket.on("close", () => {
leaveAllRooms(socket);
});
socket.on("error", (error) => {
console.error(
"WebSocket error",
{
connectionId:
socket.connectionId,
error,
},
);
});
},
);
const heartbeatInterval =
setInterval(() => {
for (
const socket
of webSocketServer.clients
) {
if (!socket.isAlive) {
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30_000);
heartbeatInterval.unref?.();
httpServer.listen(
8080,
() => {
console.log(
"HTTP and WebSocket server started",
);
},
);Функции authenticateConnection(), handleMessage() и управление комнатами должны быть реализованы в соответствии с системой аутентификации и предметной областью приложения.
Частые ошибки
Использование ws:// в production
Нежелательно:
ws://api.example.com/wsНужно:
wss://api.example.com/wsОтсутствие проверки Origin
Сам факт успешной cookie-аутентификации не означает, что соединение было открыто с доверенной страницы.
Сервер должен проверять Origin по точному allowlist.
Доверие данным клиента
Нельзя доверять:
{
"userId": "admin",
"role": "admin"
}Личность и роли определяются сервером по проверенной сессии или токену.
Отсутствие проверки прав на комнату
Нельзя разрешать присоединение к произвольной комнате:
joinRoom(
socket,
message.payload.roomId,
);Сначала требуется серверная авторизация.
Отсутствие heartbeat
Мёртвые соединения могут долго оставаться в памяти и занимать ресурсы.
Нужны ping/pong и очистка неактивных соединений.
Немедленное бесконечное переподключение
Нежелательно:
socket.onclose = connect;Нужны exponential backoff, максимальная задержка и jitter.
Отсутствие синхронизации после reconnect
События, отправленные во время отключения, не появятся автоматически.
Клиент должен запросить историю или текущее состояние.
WebSocket как единственное хранилище события
Если уведомление или сообщение существует только в отправленном фрейме, отключённый клиент его потеряет.
Критичные данные нужно сначала сохранить.
Использование Socket.IO-клиента с ws-сервером
Протоколы несовместимы:
Стандартный WebSocket клиент ↔ стандартный WebSocket сервер
Socket.IO client ↔ Socket.IO serverОтправка всем клиентам без фильтрации
Нежелательно:
for (const client of clients) {
client.send(
privateNotification,
);
}Сообщение должно отправляться только авторизованным получателям.
Неограниченный входящий JSON
Даже валидный JSON может быть огромным.
Необходимо ограничивать maxPayload, длины строк, размеры массивов и частоту сообщений.
Игнорирование backpressure
Медленный клиент может привести к росту буфера и памяти.
Нужно контролировать bufferedAmount и иметь политику для медленных соединений.
Рекомендуемая архитектура
HTTP API
├── аутентификация
├── история сообщений
├── CRUD
└── загрузка файлов
WebSocket Gateway
├── управление соединениями
├── проверка Origin
├── аутентификация
├── heartbeat
├── подписки
├── доставка событий
└── rate limiting
Application Services
├── создание сообщений
├── изменение уведомлений
└── проверка прав
Database
└── постоянные данные
Redis / Message Broker
├── межсерверная доставка
├── presence
└── временные событияWebSocket Gateway не должен содержать всю бизнес-логику. Он адаптирует сообщения к прикладным сценариям:
class ChatGateway {
constructor({
sendMessage,
joinChatRoom,
}) {
this.sendMessageUseCase =
sendMessage;
this.joinChatRoomUseCase =
joinChatRoom;
}
async handleSendMessage({
currentUser,
payload,
}) {
return this.sendMessageUseCase
.execute({
currentUser,
roomId: payload.roomId,
text: payload.text,
clientMessageId:
payload.clientMessageId,
});
}
}Это позволяет использовать одну бизнес-логику из разных интерфейсов и тестировать её без настоящего WebSocket.
Краткая памятка
WebSocket:
HTTP handshake
↓
101 Switching Protocols
↓
Постоянное двунаправленное соединение
↓
Text / Binary / Ping / Pong / CloseАдреса:
ws:// — без TLS
wss:// — с TLSВ production:
Использовать wss://HTTP и WebSocket:
HTTP — запрос и ответ
WebSocket — двусторонние сообщения в одном соединенииТипичный формат:
{
"type": "chat.message.send",
"id": "request-42",
"payload": {
"roomId": "room-7",
"text": "Привет!"
}
}Браузерный клиент:
const socket = new WebSocket(
"wss://api.example.com/ws",
);
socket.addEventListener(
"open",
() => {
console.log("Connected");
},
);
socket.addEventListener(
"message",
(event) => {
const message =
JSON.parse(event.data);
console.log(message);
},
);
socket.addEventListener(
"close",
() => {
console.log("Disconnected");
},
);Сервер на ws:
const webSocketServer =
new WebSocketServer({
server: httpServer,
path: "/ws",
maxPayload: 64 * 1024,
});Основные различия:
ws:
стандартный WebSocket, низкий уровень, больше ручной логики
Socket.IO:
события, rooms, acknowledgements, reconnect,
но собственный протокол и специальный клиентДля чата:
HTTP — история и файлы
WebSocket — новые сообщения, typing, presence
Database — постоянное хранение сообщенийДля уведомлений:
Сначала сохранить уведомление
Затем отправить активным соединениям
После reconnect загрузить пропущенные данныеОсновные правила:
- используйте
wss://в production; - проверяйте
Origin; - аутентифицируйте соединение;
- авторизовывайте каждую команду;
- не доверяйте
userIdи ролям от клиента; - валидируйте тип и структуру каждого сообщения;
- ограничивайте размер и частоту сообщений;
- используйте ping/pong для поиска мёртвых соединений;
- применяйте exponential backoff и jitter при reconnect;
- восстанавливайте подписки после переподключения;
- синхронизируйте пропущенные данные через историю или event ID;
- используйте идентификаторы операций и acknowledgements;
- делайте повторяемые команды идемпотентными;
- контролируйте backpressure;
- сохраняйте критичные сообщения до real-time-доставки;
- не используйте WebSocket как единственное хранилище событий;
- не выводите сообщения чата через небезопасный
innerHTML; - используйте общий broker при нескольких экземплярах сервера;
- не путайте стандартный WebSocket и Socket.IO;
- собирайте метрики активных соединений, сообщений, ошибок и закрытий.