WebSockets vs SSE vs Long Polling: выбор технологии realtime для вашего приложения
Когда нужно показывать данные в реальном времени — чат, статус заказа, онлайн-счётчики, уведомления — встаёт вопрос: какую технологию выбрать? Каждая имеет свои сценарии, ограничения и компромиссы.
Краткое сравнение
| Критерий | WebSocket | SSE | Long Polling |
|---|---|---|---|
| Направление | Двунаправленный | Сервер → клиент | Сервер → клиент |
| Протокол | ws:// wss:// | HTTP | HTTP |
| Автоматическое переподключение | Нет (вручную) | Да (браузер) | Зависит от реализации |
| Поддержка браузеров | 98%+ | 98%+ (нет IE) | 100% |
| Поддержка прокси/балансировщиков | Требует настройки | Нативно | Нативно |
| Нагрузка на сервер | Низкая (persistent conn) | Низкая | Высокая |
| Сложность реализации | Высокая | Низкая | Средняя |
Long Polling — простейший вариант
Клиент делает HTTP-запрос. Сервер держит соединение открытым до появления данных (или таймаута), затем отвечает. Клиент немедленно делает следующий запрос.
// Сервер (Express)
const pendingClients = new Map<string, Response>();
app.get('/api/notifications/poll', authenticate, async (req, res) => {
const userId = req.user.id;
res.setTimeout(30_000); // 30 секунд таймаут
// Ставим в очередь ожидающих клиентов
pendingClients.set(userId, res);
req.on('close', () => {
pendingClients.delete(userId);
});
});
// Когда приходит уведомление — отправляем ожидающему клиенту
async function sendNotification(userId: string, notification: Notification) {
const client = pendingClients.get(userId);
if (client) {
client.json(notification);
pendingClients.delete(userId);
}
// Также сохраняем в БД для клиентов не онлайн
await db.notification.create({ data: { userId, ...notification } });
}
// Клиент
async function startLongPolling(userId: string) {
while (true) {
try {
const response = await fetch('/api/notifications/poll', {
signal: AbortSignal.timeout(35_000),
});
if (response.ok) {
const notification = await response.json();
displayNotification(notification);
}
} catch {
// Таймаут или ошибка — ждём и повторяем
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
}
Когда использовать Long Polling:
- Нечастые обновления (раз в минуту и реже)
- Простота важнее оптимальности
- Нет возможности настроить WebSocket на прокси
- Нужна максимальная совместимость
Server-Sent Events (SSE)
Браузер открывает HTTP-соединение и получает события потоком. Нативная поддержка в браузере включает автоматическое переподключение.
// Сервер
const sseClients = new Map<string, Response[]>();
app.get('/api/events', authenticate, (req, res) => {
const userId = req.user.id;
// Заголовки SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // для Nginx
res.flushHeaders();
// Регистрируем клиента
const clients = sseClients.get(userId) ?? [];
clients.push(res);
sseClients.set(userId, clients);
// Heartbeat — предотвращает таймаут прокси
const heartbeat = setInterval(() => {
res.write(':heartbeat\n\n');
}, 25_000);
req.on('close', () => {
clearInterval(heartbeat);
const remaining = (sseClients.get(userId) ?? []).filter(c => c !== res);
if (remaining.length === 0) {
sseClients.delete(userId);
} else {
sseClients.set(userId, remaining);
}
});
});
// Отправка событий клиенту
function sendEvent(userId: string, eventType: string, data: object) {
const clients = sseClients.get(userId) ?? [];
const message = `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
clients.forEach(client => client.write(message));
}
// Использование:
sendEvent(userId, 'order_status', { orderId: '123', status: 'shipped' });
sendEvent(userId, 'notification', { text: 'Ваш заказ отправлен!' });
// Клиент — браузер делает всё автоматически
const eventSource = new EventSource('/api/events', { withCredentials: true });
eventSource.addEventListener('order_status', (e) => {
const data = JSON.parse(e.data);
updateOrderStatus(data.orderId, data.status);
});
eventSource.addEventListener('notification', (e) => {
const data = JSON.parse(e.data);
showNotification(data.text);
});
// Автоматическое переподключение — браузер делает это сам
eventSource.onerror = (err) => {
console.log('SSE ошибка, браузер переподключится автоматически');
};
Когда использовать SSE:
- Обновления только от сервера к клиенту (уведомления, статусы)
- Нужно простое автоматическое переподключение
- Работа за стандартными HTTP балансировщиками
- Нет необходимости в двунаправленном обмене
WebSocket — двунаправленный канал
WebSocket создаёт постоянное соединение для обмена данными в обе стороны. Идеален для чатов, совместного редактирования, online-игр.
Нативный WebSocket (без библиотек)
import { WebSocketServer, WebSocket } from 'ws';
import http from 'http';
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
interface AuthenticatedWS extends WebSocket {
userId?: string;
isAlive?: boolean;
}
const connectedUsers = new Map<string, Set<AuthenticatedWS>>();
wss.on('connection', async (ws: AuthenticatedWS, req) => {
// Аутентификация через token в URL или cookie
const token = new URL(req.url!, 'ws://localhost').searchParams.get('token');
const userId = await verifyToken(token);
if (!userId) {
ws.close(4001, 'Unauthorized');
return;
}
ws.userId = userId;
ws.isAlive = true;
const userConns = connectedUsers.get(userId) ?? new Set();
userConns.add(ws);
connectedUsers.set(userId, userConns);
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString());
await handleMessage(ws, message);
} catch (err) {
ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' }));
}
});
ws.on('pong', () => { ws.isAlive = true; });
ws.on('close', () => {
const conns = connectedUsers.get(userId!);
conns?.delete(ws);
if (conns?.size === 0) connectedUsers.delete(userId!);
});
});
// Ping/pong для обнаружения мёртвых соединений
setInterval(() => {
wss.clients.forEach((ws: AuthenticatedWS) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30_000);
// Отправка пользователю (на все его вкладки/устройства)
function sendToUser(userId: string, data: object) {
const connections = connectedUsers.get(userId);
connections?.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(data));
}
});
}
Socket.io — когда нужна надёжность
Socket.io добавляет: автоматические переподключения, fallback на polling, rooms, namespaces:
import { Server } from 'socket.io';
const io = new Server(server, {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
transports: ['websocket', 'polling'], // polling как fallback
});
// Middleware для аутентификации
io.use(async (socket, next) => {
try {
const token = socket.handshake.auth.token;
socket.data.userId = await verifyToken(token);
next();
} catch {
next(new Error('Unauthorized'));
}
});
io.on('connection', (socket) => {
const { userId } = socket.data;
// Пользователь присоединяется к персональной комнате
socket.join(`user:${userId}`);
socket.on('chat:message', async (data: { roomId: string; text: string }) => {
const message = await db.message.create({
data: { userId, roomId: data.roomId, text: data.text },
});
// Отправляем всем в комнате
io.to(`room:${data.roomId}`).emit('chat:message', message);
});
socket.on('join:room', (roomId: string) => {
socket.join(`room:${roomId}`);
});
});
// Уведомление конкретному пользователю (работает даже на нескольких серверах с Redis)
function notifyUser(userId: string, event: string, data: object) {
io.to(`user:${userId}`).emit(event, data);
}
Масштабирование: Redis Pub/Sub
На нескольких серверах WebSocket-соединения распределены по разным узлам. Redis Pub/Sub синхронизирует их:
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
// Теперь io.to('room:xxx').emit() работает на всех серверах
Выбор для конкретных задач
| Задача | Рекомендация |
|---|---|
| Push-уведомления | SSE |
| Статус заказа/доставки | SSE |
| Чат | WebSocket |
| Совместное редактирование | WebSocket |
| Live-счётчики на сайте | SSE |
| Онлайн-игры | WebSocket (нативный) |
| Редкие обновления (<1/мин) | Long Polling |
Aunimeda реализует realtime-функциональность для бизнес-приложений. WhatsApp.
Смотрите также: Telegram Bot FSM и middleware, Node.js vs Bun runtime