Данные в реальном времени: WebSockets, SSE и когда они действительно нужны
Понимание паттернов работы с данными в реальном времени — от WebSockets до Server-Sent Events, Firebase Realtime Database, и знание, когда обычный опрос (polling) вполне достаточен.

Большинству приложений, которые заявляют о необходимости данных в реальном времени, на самом деле не нужны данные в реальном времени. Им нужны данные, которые кажутся актуальными — обновляемые в течение нескольких секунд, а не нескольких миллисекунд. Это различие важно, потому что истинные архитектуры реального времени вносят сложность в управление соединениями, синхронизацию состояний, масштабирование и обработку ошибок, что совершенно излишне, если ваши пользователи будут вполне довольны пятисекундной задержкой.
Я создавал по-настоящему реальные функции — отслеживание заказов в реальном времени для ресторанной платформы, интерфейсы для совместного редактирования, интерактивные дашборды — а также функции, где я изначально переусложнял с помощью WebSockets, а затем заменял их опросом, обновляющимся каждые десять секунд. Никто не заметил изменения. Начинать с простейшего подхода и добавлять сложность только тогда, когда пользователи действительно сталкиваются с проблемой, — это не просто прагматично, это ответственный инженерный выбор.
Когда вам действительно нужны данные в реальном времени
Истинное реальное время оправдано, когда ценность информации быстро снижается с увеличением задержки.
Чаты и мессенджеры. Пользователи ожидают, что сообщения появятся в течение секунды. Пятисекундная задержка делает разговор прерывистым. Здесь реальное время не является опцией.
Совместное редактирование. Одновременное редактирование в стиле Google Docs требует синхронизации менее чем за секунду, чтобы избежать конфликтующих изменений. Сложность здесь выходит за рамки простого транспорта — для разрешения конфликтов вам нужны операционные преобразования (operational transformation) или CRDT.
Интерактивные дашборды с операционным влиянием. Дашборд мониторинга, где инженер отслеживает аномалии во время развертывания, нуждается в обновлениях в реальном времени. Дашборд бизнес-аналитики, просматриваемый раз в день, — нет.
Игры и интерактивные приложения. Многопользовательские игры, живые аукционы, опросы в реальном времени — все, где несколько пользователей одновременно взаимодействуют с общим состоянием.
Финансовая торговля. Ленты цен, обновления стакана заявок, изменения позиций. Здесь важны миллисекунды, и архитектура это отражает.
Когда вам не нужны данные в реальном времени
Ленты социальных сетей. Twitter и Instagram кажутся работающими в реальном времени, но они используют комбинацию опроса при фокусе и push-уведомлений. Лента не обновляется, пока вы на нее смотрите — вы тянете, чтобы обновить.
Инвентарь электронной коммерции. «Осталось всего 3!» не требует обновления в реальном времени. Проверки при загрузке страницы и при оформлении заказа достаточно.
Количество уведомлений. Красный значок, показывающий «5 новых уведомлений», можно опрашивать каждые 30 секунд. Пользователи не заметят, если уведомление появится через 30 секунд после его создания.
Обновления контента. Записи в блогах, списки товаров, профили пользователей — любые данные, которые меняются нечасто. Опрашивайте при загрузке страницы или используйте заголовки HTTP-кеша с повторной валидацией.
Опрос (Polling): Недооцененный стандартный подход
Опрос (polling) — выполнение периодических HTTP-запросов для проверки наличия новых данных — является простейшим подходом и работает для большего числа сценариев, чем предполагает большинство разработчиков.
Простой опрос
function usePolledData<T>(url: string, intervalMs: number = 5000) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let active = true;
const fetchData = async () => {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
if (active) setData(result);
} catch (e) {
if (active) setError(e as Error);
}
};
fetchData(); // Initial fetch
const interval = setInterval(fetchData, intervalMs);
return () => {
active = false;
clearInterval(interval);
};
}, [url, intervalMs]);
return { data, error };
}
Умный опрос с условными запросами
Условные HTTP-запросы (If-None-Match с ETags или If-Modified-Since) позволяют эффективно выполнять опрос. Сервер возвращает 304 Not Modified без тела, если данные не изменились, что снижает пропускную способность почти до нуля для неизмененных ресурсов.
async function pollWithETag(url: string): Promise<{ data: any; changed: boolean }> {
const cachedETag = etagCache.get(url);
const headers: HeadersInit = {};
if (cachedETag) {
headers['If-None-Match'] = cachedETag;
}
const response = await fetch(url, { headers });
if (response.status === 304) {
return { data: dataCache.get(url), changed: false };
}
const etag = response.headers.get('ETag');
if (etag) etagCache.set(url, etag);
const data = await response.json();
dataCache.set(url, data);
return { data, changed: true };
}
Когда опрос перестает работать
Опрос перестает быть жизнеспособным, когда:
- Вам нужна задержка менее секунды. Опрос каждые 500 мс — это, по сути, DoS-атака на ваш собственный API.
- Данные меняются редко, но должны доставляться мгновенно. Опрос каждые 5 секунд для события, которое происходит раз в час, тратит 719 запросов в час.
- У вас тысячи клиентов. Каждый опрашивающий клиент — это независимое соединение. В масштабе накладные расходы HTTP на установление соединений, разбор заголовков и аутентификацию накапливаются.
Server-Sent Events: Более простое реальное время
Server-Sent Events (SSE) — это самая недооцененная технология реального времени в веб-разработке. SSE предоставляет односторонний поток от сервера к клиенту по стандартному HTTP-соединению. Браузер автоматически обрабатывает переподключение, а протокол тривиально прост.
Реализация на сервере
// Express.js SSE endpoint
app.get('/events/orders/:restaurantId', (req, res) => {
const { restaurantId } = req.params;
// SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
// Send initial data
const initialOrders = getActiveOrders(restaurantId);
res.write(`data: ${JSON.stringify(initialOrders)}\n\n`);
// Subscribe to order changes
const unsubscribe = orderEmitter.on(`orders:${restaurantId}`, (order) => {
res.write(`event: orderUpdate\n`);
res.write(`data: ${JSON.stringify(order)}\n`);
res.write(`id: ${order.id}-${order.updatedAt}\n\n`);
});
// Heartbeat to detect dead connections
const heartbeat = setInterval(() => {
res.write(`: heartbeat\n\n`);
}, 30000);
// Cleanup on disconnect
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});
});
Реализация на клиенте
function useSSE(url: string) {
const [data, setData] = useState(null);
const [connected, setConnected] = useState(false);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onopen = () => setConnected(true);
eventSource.onmessage = (event) => {
setData(JSON.parse(event.data));
};
eventSource.addEventListener('orderUpdate', (event) => {
const order = JSON.parse(event.data);
setData(prev => updateOrderInList(prev, order));
});
eventSource.onerror = () => {
setConnected(false);
// EventSource automatically reconnects
};
return () => eventSource.close();
}, [url]);
return { data, connected };
}
Почему SSE вместо WebSockets
Автоматическое переподключение. Реализация EventSource в браузере автоматически обрабатывает переподключение с экспоненциальной задержкой. С WebSockets вам придется реализовывать это самостоятельно.
Работает через прокси и балансировщики нагрузки. SSE использует стандартный HTTP, поэтому он работает через nginx, CloudFlare и большинство обратных прокси без специальной настройки. WebSockets требуют явной поддержки прокси.
Более простая аутентификация. Соединения SSE передают куки и могут использовать стандартные заголовки HTTP-аутентификации. Аутентификация WebSocket требует отправки учетных данных после установления соединения, что является дополнительным протоколом для реализации.
Встроенные типы событий и идентификаторы. Протокол SSE поддерживает именованные события и идентификаторы сообщений, что позволяет клиенту возобновить работу с того места, где он остановился после переподключения.
Ограничения SSE
- Только односторонний. От сервера к клиенту. Если вам нужна двусторонняя связь, SSE не может ее обеспечить. Вы можете использовать SSE в паре с обычными HTTP POST-запросами для сообщений от клиента к серверу, что хорошо работает для многих сценариев.
- Ограничения браузерных соединений. Браузеры ограничивают количество одновременных HTTP-соединений к одному домену (обычно шесть). Каждое SSE-соединение учитывается в этом лимите. Мультиплексирование HTTP/2 смягчает эту проблему, но об этом стоит знать.
- Нет бинарных данных. SSE работает только с текстом. Если вам нужно передавать бинарные данные (аудио, видео, файлы), используйте WebSockets или отдельный механизм.
WebSockets: Полнодуплексная связь
WebSockets обеспечивают полнодуплексную связь — как клиент, так и сервер могут отправлять сообщения в любое время без накладных расходов на циклы HTTP-запрос/ответ. Это необходимо, когда вам нужна двусторонняя связь с низкой задержкой.
Реализация на сервере с ws
import { WebSocketServer, WebSocket } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server });
// Connection management
const rooms = new Map<string, Set<WebSocket>>();
wss.on('connection', (ws, req) => {
// Authenticate the connection
const token = new URL(req.url!, `http://${req.headers.host}`).searchParams.get('token');
const user = verifyToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
// Attach user data
(ws as any).userId = user.id;
ws.on('message', (raw) => {
try {
const message = JSON.parse(raw.toString());
handleMessage(ws, user, message);
} catch (e) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
// Remove from all rooms
rooms.forEach((clients, roomId) => {
clients.delete(ws);
if (clients.size === 0) rooms.delete(roomId);
});
});
// Heartbeat
(ws as any).isAlive = true;
ws.on('pong', () => { (ws as any).isAlive = true; });
});
// Dead connection detection
const heartbeatInterval = setInterval(() => {
wss.clients.forEach((ws) => {
if (!(ws as any).isAlive) {
ws.terminate();
return;
}
(ws as any).isAlive = false;
ws.ping();
});
}, 30000);
function handleMessage(ws: WebSocket, user: any, message: any) {
switch (message.type) {
case 'join_room':
joinRoom(ws, message.roomId);
break;
case 'leave_room':
leaveRoom(ws, message.roomId);
break;
case 'broadcast':
broadcastToRoom(message.roomId, {
type: 'message',
userId: user.id,
content: message.content,
timestamp: Date.now(),
}, ws);
break;
}
}
function joinRoom(ws: WebSocket, roomId: string) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
function broadcastToRoom(roomId: string, data: any, exclude?: WebSocket) {
const clients = rooms.get(roomId);
if (!clients) return;
const payload = JSON.stringify(data);
clients.forEach((client) => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(payload);
}
});
}
server.listen(8080);
Реализация на клиенте с переподключением
class WebSocketClient {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private handlers = new Map<string, Set<(data: any) => void>>();
constructor(url: string) {
this.url = url;
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.emit('connected', null);
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.emit(message.type, message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
this.ws.onclose = (event) => {
this.emit('disconnected', { code: event.code, reason: event.reason });
if (event.code !== 4001 && this.reconnectAttempts < this.maxReconnectAttempts) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// Error is followed by close event, handle reconnection there
};
}
private scheduleReconnect() {
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => {
this.emit('reconnecting', { attempt: this.reconnectAttempts });
this.connect();
}, delay);
}
send(type: string, data: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type, ...data }));
}
}
on(event: string, handler: (data: any) => void) {
if (!this.handlers.has(event)) this.handlers.set(event, new Set());
this.handlers.get(event)!.add(handler);
return () => this.handlers.get(event)?.delete(handler);
}
private emit(event: string, data: any) {
this.handlers.get(event)?.forEach((handler) => handler(data));
}
disconnect() {
this.maxReconnectAttempts = 0; // Prevent reconnection
this.ws?.close();
}
}
Стратегия переподключения
Логика переподключения заслуживает внимания, потому что она напрямую влияет на пользовательский опыт и нагрузку на сервер.
Экспоненциальная задержка (exponential backoff) предотвращает проблему «громогласного стада» (thundering herd). Если ваш сервер перезапускается и 10 000 клиентов одновременно переподключаются, сервер снова падает. Добавляя джиттер и экспоненциальные задержки, соединения распределяются во времени:
function getReconnectDelay(attempt: number): number {
const baseDelay = 1000;
const maxDelay = 30000;
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000; // Random jitter up to 1 second
return Math.min(exponentialDelay + jitter, maxDelay);
}
Пользовательский интерфейс состояния соединения имеет значение. Пользователям необходимо знать, когда они отключены и когда приложение пытается переподключиться. Небольшой баннер с надписью «Переподключение...» лучше, чем молчаливое отображение устаревших данных.
Socket.IO: Удобство против контроля
Socket.IO — самая популярная библиотека WebSocket, и она предоставляет значительные удобства: автоматическое переподключение, управление комнатами, подтверждения и откат к опросу, когда WebSockets недоступны. Она также добавляет значительные накладные расходы и вводит уровень абстракции, который может скрывать проблемы.
Когда использовать Socket.IO
- Быстрое прототипирование. API Socket.IO проще, чем у «чистых» WebSockets, а встроенные функции экономят время разработки.
- Когда вам нужен откат к опросу. Некоторые корпоративные сети блокируют соединения WebSocket. Socket.IO автоматически переключается на HTTP long polling.
- Управление комнатами и пространствами имен. Если ваша функция реального времени естественным образом отображается в комнаты (чаты, игровые лобби, совместные документы), абстракции комнат Socket.IO экономят шаблонный код.
Когда следует избегать Socket.IO
- Высокопроизводительные приложения. Протокол Socket.IO добавляет накладные расходы к каждому сообщению (кадрирование, кодирование, метаданные). Для высокопроизводительных приложений «чистые» WebSockets заметно быстрее.
- Когда вам нужна совместимость. Socket.IO не является стандартным WebSocket — обычный клиент WebSocket не может подключиться к серверу Socket.IO. Если ваши клиенты включают не-JavaScript среды (мобильные приложения, IoT-устройства), Socket.IO требует клиентской библиотеки для каждой платформы.
- Когда вы хотите понять, что происходит. Socket.IO абстрагирует управление соединениями, что удобно, пока что-то не пойдет не так. Отладка проблемы с соединением Socket.IO часто означает понимание уровня абстракции поверх фактической проблемы.
Firebase Realtime Database и Firestore
Firebase предоставляет возможности реального времени без управления какой-либо инфраструктурой WebSocket. Это его основное ценностное предложение — вы обмениваете контроль на удобство.
Firebase Realtime Database
import { getDatabase, ref, onValue, set } from 'firebase/database';
const db = getDatabase();
// Listen for real-time updates
const ordersRef = ref(db, `restaurants/${restaurantId}/orders`);
onValue(ordersRef, (snapshot) => {
const orders = snapshot.val();
updateUI(orders);
});
// Write data (triggers listeners on all connected clients)
await set(ref(db, `restaurants/${restaurantId}/orders/${orderId}`), {
status: 'preparing',
updatedAt: Date.now(),
});
Realtime Database — это JSON-дерево. Каждая запись в любой узел немедленно распространяется на каждого клиента, слушающего этот узел или любой родительский узел. Это мощно, но опасно — слушатель на узле высокого уровня получает обновления для каждого изменения во всем поддереве.
Слушатели Firestore в реальном времени
import { getFirestore, collection, onSnapshot, query, where } from 'firebase/firestore';
const db = getFirestore();
// Listen for active orders
const q = query(
collection(db, 'orders'),
where('restaurantId', '==', restaurantId),
where('status', 'in', ['pending', 'preparing', 'ready'])
);
const unsubscribe = onSnapshot(q, (snapshot) => {
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') addOrder(change.doc.data());
if (change.type === 'modified') updateOrder(change.doc.data());
if (change.type === 'removed') removeOrder(change.doc.id);
});
});
Слушатели реального времени Firestore более гранулярны, чем Realtime Database — вы можете слушать конкретные запросы, а не только пути. Метод docChanges() точно сообщает, что изменилось, что делает возможным эффективное обновление пользовательского интерфейса.
Supabase Realtime
Supabase предлагает возможности реального времени поверх PostgreSQL, что дает вам мощь запросов SQL с обновлениями в реальном времени. Он использует функции репликации PostgreSQL (логическая репликация и WAL) для обнаружения изменений и их трансляции через каналы WebSocket.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, key);
// Listen for changes to the orders table
const channel = supabase
.channel('orders')
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'orders',
filter: `restaurant_id=eq.${restaurantId}`,
},
(payload) => {
console.log('Change type:', payload.eventType);
console.log('New data:', payload.new);
console.log('Old data:', payload.old);
}
)
.subscribe();
Преимущество перед Firebase очевидно: ваши данные хранятся в PostgreSQL с полными возможностями SQL-запросов, ACID-транзакциями и стандартными инструментами. Уровень реального времени является дополнением, а не заменой вашего движка запросов. Компромисс заключается в том, что Supabase Realtime менее проверен в масштабе, чем Firebase, а производительность в реальном времени зависит от пропускной способности репликации PostgreSQL.
Масштабирование WebSocket-соединений
Один сервер может обрабатывать десятки тысяч WebSocket-соединений, но масштабирование за пределы одного сервера вводит проблему координации: сообщение, опубликованное на Сервере A, должно достичь клиентов, подключенных к Серверу B.
Паттерн Pub/Sub
Redis pub/sub — это стандартное решение:
import { createClient } from 'redis';
const publisher = createClient();
const subscriber = createClient();
await publisher.connect();
await subscriber.connect();
// When a message comes in on any server, publish to Redis
function publishToRoom(roomId: string, message: any) {
publisher.publish(`room:${roomId}`, JSON.stringify(message));
}
// Each server subscribes and forwards to its local connections
await subscriber.subscribe(`room:${roomId}`, (message) => {
const data = JSON.parse(message);
broadcastToLocalClients(roomId, data);
});
Этот паттерн позволяет запускать несколько WebSocket-серверов за балансировщиком нагрузки. Каждый сервер обрабатывает свои собственные соединения, а Redis координирует межсерверную связь.
Sticky Sessions (Липкие сессии)
WebSocket-соединения являются stateful (с сохранением состояния) — после установления они должны оставаться на одном и том же сервере. Балансировщикам нагрузки нужны sticky sessions (также называемые session affinity), чтобы маршрутизировать WebSocket-соединение клиента к тому же серверу, который обрабатывал первоначальное HTTP-обновление.
С nginx:
upstream websocket_servers {
ip_hash; # Sticky sessions based on client IP
server ws1.example.com:8080;
server ws2.example.com:8080;
}
server {
location /ws {
proxy_pass http://websocket_servers;
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 hours
}
}
Реальное время против почти реального времени
Существует спектр между «проверять обновления, когда пользователь обновляет страницу» и «доставлять каждое изменение в течение миллисекунд». Большинство функций находятся где-то посередине.
| Подход | Задержка | Сложность | Лучше всего подходит для |
|---|---|---|---|
| Обновление страницы | Инициируется пользователем | Нет | Статический контент, нечастые изменения |
| Опрос (30 с) | До 30 секунд | Низкая | Количество уведомлений, обновления дашбордов |
| Опрос (5 с) | До 5 секунд | Низкая | Ленты активности, статус заказа |
| Длительный опрос (Long polling) | Менее секунды | Умеренная | Старые системы, среды, враждебные к прокси |
| SSE | Менее секунды | Умеренная | Живые ленты, уведомления, односторонние потоки |
| WebSockets | Менее секунды | Высокая | Чаты, совместная работа, двустороннее реальное время |
| WebRTC | Миллисекунды | Очень высокая | Видео/аудио, peer-to-peer |
Правильный подход — это самый простой, который соответствует вашим требованиям к задержке. Начните с опроса. Если пользователи жалуются на устаревшие данные, перейдите на SSE. Если вам нужна двусторонняя связь, используйте WebSockets. Каждый шаг вверх по лестнице сложности должен быть оправдан конкретной потребностью пользователя, а не гипотетическим будущим требованием.
Практические рекомендации
После создания функций реального времени в нескольких проектах, я постоянно возвращаюсь к этим паттернам:
По умолчанию используйте SSE для одностороннего реального времени. Большинство функций реального времени работают по принципу «сервер-клиент»: обновления заказов, потоки уведомлений, живые потоки данных. SSE справляется со всем этим с меньшей сложностью, чем WebSockets.
Используйте WebSockets только для двусторонней связи. Чаты, совместное редактирование, многопользовательские взаимодействия — все это действительно требует WebSockets. Если ваш клиент только получает данные, SSE проще.
Всегда реализуйте переподключение с экспоненциальной задержкой. Соединения обрываются. Сети переключаются. Серверы перезапускаются. Ваш клиент должен обрабатывать это корректно, а ваш сервер должен выдерживать шторм переподключений.
Отправляйте дельты, а не полное состояние. Вместо того чтобы отправлять весь список заказов каждый раз, когда меняется один заказ, отправляйте только измененный заказ. Клиент применяет дельту к своему локальному состоянию. Это уменьшает пропускную способность и обработку на обеих сторонах.
Имейте запасной вариант. Если ваше WebSocket-соединение прерывается и не может переподключиться, приложение все равно должно быть функциональным. Индикатор «последнее обновление 30 секунд назад» с ручным обновлением лучше, чем пустой экран.
Мониторьте количество соединений в продакшене. WebSocket-соединения потребляют серверные ресурсы (память, файловые дескрипторы). Внезапный всплеск соединений — например, из-за ошибки клиента, вызывающей быстрые переподключения — может привести к падению вашего сервера. Настройте оповещения об аномалиях в количестве соединений.
Цель состоит не в том, чтобы иметь самую сложную архитектуру реального времени. Цель состоит в том, чтобы доставлять данные пользователям достаточно быстро, чтобы приложение ощущалось отзывчивым, с наименьшей сложностью инфраструктуры, которая обеспечивает это ощущение.