← все статьи
7 мин

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 только при реальной необходимости.

← все статьи Следующая →Оптимизация стоимости LLM в продакшне