WebSocket vs SSE vs Long Polling: чек-лист выбора
Каждый раз, когда появляется задача «нужно real-time», начинается одна и та же дискуссия. «Давайте WebSocket!» — говорит один. «Зачем, SSE хватит» — говорит другой. «А что если просто polling?» — добавляет третий. Все три варианта рабочие. Все три — неправильные в чужом контексте. Разберём каждый без маркетинга.
Три протокола: механика за 60 секунд
Long Polling — клиент отправляет HTTP-запрос, сервер держит его открытым до появления данных (или таймаута), возвращает ответ, клиент сразу делает следующий запрос. Классический HTTP под капотом, никаких новых протоколов. Задержка — от десятков до сотен миллисекунд в зависимости от реализации.
Server-Sent Events (SSE) — клиент открывает одно HTTP-соединение, сервер стримит события в формате text/event-stream. Однонаправленно: только сервер → клиент. Браузер поддерживает нативно через EventSource, автоматически переподключается при обрыве.
WebSocket — полнодуплексное соединение поверх TCP. Начинается с HTTP Upgrade-рукопожатия, затем переходит на бинарный фрейминг. Данные текут в обе стороны независимо, без заголовков HTTP на каждое сообщение. Минимальный overhead: 2–10 байт на фрейм против ~200–800 байт HTTP-заголовков.
Чек-лист: что выбрать
Прежде чем выбирать протокол, ответьте на три вопроса:
1. Нужна ли двусторонняя связь?
Клиент только читает события (лента уведомлений, статус задачи, live-dashboard) → SSE достаточно.
Клиент и сам отправляет данные в реальном времени (чат, совместное редактирование, мультиплеерная игра) → WebSocket.
2. Важна ли поддержка legacy-окружений и HTTP/2?
SSE работает поверх обычного HTTP/1.1 и HTTP/2, хорошо дружит с прокси и CDN.
WebSocket требует отдельной конфигурации на nginx/балансировщиках, некоторые корпоративные прокси его блокируют.
Long Polling работает везде, где есть HTTP.
3. Какой масштаб и частота событий?
Редкие события (<1/сек на пользователя) → SSE или Long Polling, нет смысла держать постоянное соединение.
Частые события (5+/сек) или низкая латентность критична → WebSocket.
Итоговая таблица:
Сценарий Выбор
-------------------------------+-------------
Чат, мультиплеер, торги WebSocket
Live-лента, уведомления SSE
Статус фоновой задачи SSE
Dashboard с метриками SSE
Legacy-интеграция, CORS-strict Long Polling
IoT, бинарные данные WebSocket
Совместное редактирование WebSocket
FastAPI: реализация WebSocket и SSE
Покажем оба варианта на Python с FastAPI. Сначала WebSocket-чат:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from typing import List
app = FastAPI()
class ConnectionManager:
def __init__(self):
self.active: List[WebSocket] = []
async def connect(self, ws: WebSocket):
await ws.accept()
self.active.append(ws)
def disconnect(self, ws: WebSocket):
self.active.remove(ws)
async def broadcast(self, message: str):
for connection in self.active:
await connection.send_text(message)
manager = ConnectionManager()
@app.websocket("/ws/chat")
async def chat_endpoint(ws: WebSocket):
await manager.connect(ws)
try:
while True:
data = await ws.receive_text()
await manager.broadcast(f"message: {data}")
except WebSocketDisconnect:
manager.disconnect(ws)
await manager.broadcast("system: user disconnected")
Теперь SSE-эндпоинт для стриминга событий (например, live-лента статусов):
import asyncio
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
async def event_generator(request: Request):
"""Генератор SSE-событий. Завершается при разрыве соединения."""
event_id = 0
while True:
if await request.is_disconnected():
break
# Получаем данные из очереди / БД / внешнего источника
payload = await fetch_latest_event()
yield (
f"id: {event_id}\n"
f"event: status_update\n"
f"data: {payload}\n\n"
)
event_id += 1
await asyncio.sleep(1)
@app.get("/sse/events")
async def sse_endpoint(request: Request):
return StreamingResponse(
event_generator(request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # отключаем буферизацию nginx
},
)
async def fetch_latest_event() -> str:
# Заглушка: в реальности — Redis, DB, очередь
return '{"status": "running", "progress": 42}'
Заголовок X-Accel-Buffering: no критичен: без него nginx буферизует ответ и клиент не получает события в реальном времени.
Масштабирование: sticky sessions и Redis PubSub
Одна нода держит соединения в памяти. Два экземпляра сервиса — и сообщение, пришедшее на первую ноду, не доходит до клиентов на второй. Это главная ловушка горизонтального масштабирования real-time.
Два подхода:
Sticky sessions (session affinity) — балансировщик направляет все запросы одного клиента на одну ноду. Просто настроить, но создаёт неравномерную нагрузку и точку отказа. При падении ноды все её соединения обрываются. Для небольших систем приемлемо.
Redis PubSub — каждая нода подписана на общий канал в Redis. Входящее сообщение публикуется в Redis, все ноды его получают и раздают своим подключённым клиентам. Работает корректно при любом числе нод.
import redis.asyncio as aioredis
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
redis = aioredis.from_url("redis://localhost:6379")
# Локальный реестр соединений на этой ноде
local_connections: list[WebSocket] = []
@app.on_event("startup")
async def subscribe_to_redis():
"""Подписываемся на канал и раздаём сообщения локальным клиентам."""
pubsub = redis.pubsub()
await pubsub.subscribe("chat:global")
async def listener():
async for message in pubsub.listen():
if message["type"] == "message":
text = message["data"].decode()
for ws in local_connections:
await ws.send_text(text)
import asyncio
asyncio.create_task(listener())
@app.websocket("/ws/chat")
async def chat_ws(ws: WebSocket):
await ws.accept()
local_connections.append(ws)
try:
while True:
data = await ws.receive_text()
# Публикуем в Redis → все ноды получат и раздадут
await redis.publish("chat:global", data)
except WebSocketDisconnect:
local_connections.remove(ws)
Проксирование через nginx
Стандартный nginx-конфиг обрывает WebSocket: он не передаёт заголовки Upgrade и Connection. Минимальная конфигурация:
upstream backend {
server 127.0.0.1:8000;
# Для sticky sessions:
# ip_hash;
}
server {
listen 443 ssl;
server_name qdev.run;
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Таймаут соединения: 0 = держим вечно
proxy_read_timeout 0;
proxy_send_timeout 0;
}
location /sse/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_buffering off; # критично для SSE
proxy_cache off;
proxy_read_timeout 3600s;
}
}
Для SSE: proxy_buffering off обязателен. Без него nginx накапливает ответ и клиент получает все события сразу, пачкой, а не по одному.
Fallback-стратегия
В идеальном мире WebSocket работает везде. На практике корпоративные прокси, старые браузеры и некоторые облачные WAF его режут. Рабочая fallback-цепочка:
class RealtimeClient {
constructor(url) {
this.url = url;
this.handlers = {};
}
connect() {
// 1. Пробуем WebSocket
if (typeof WebSocket !== "undefined") {
this._tryWebSocket();
} else {
// 2. Fallback на SSE
this._trySSE();
}
}
_tryWebSocket() {
const ws = new WebSocket(this.url.replace(/^http/, "ws") + "/ws");
ws.onopen = () => console.log("WebSocket connected");
ws.onmessage = (e) => this._emit("message", e.data);
ws.onerror = () => {
console.warn("WebSocket failed, falling back to SSE");
ws.close();
this._trySSE();
};
}
_trySSE() {
if (typeof EventSource !== "undefined") {
const es = new EventSource(this.url + "/sse/events");
es.onmessage = (e) => this._emit("message", e.data);
es.onerror = () => {
console.warn("SSE failed, falling back to Long Polling");
es.close();
this._startLongPolling();
};
} else {
this._startLongPolling();
}
}
_startLongPolling() {
const poll = async () => {
try {
const res = await fetch(this.url + "/poll");
const data = await res.json();
this._emit("message", JSON.stringify(data));
} catch (_) {}
setTimeout(poll, 2000);
};
poll();
}
_emit(event, data) {
if (this.handlers[event]) this.handlers[event](data);
}
on(event, handler) {
this.handlers[event] = handler;
return this;
}
}
Цепочка: WebSocket → SSE → Long Polling. Клиент сам деградирует до работающего транспорта, сервер отдаёт все три эндпоинта.
Не существует «лучшего» real-time протокола. WebSocket нужен, когда клиент отправляет данные с высокой частотой. SSE закрывает 80% задач дешевле и проще. Long Polling — когда всё остальное заблокировано инфраструктурой. Начинайте с SSE и переходите на WebSocket только при реальной необходимости.