WebSocket, long polling или SSE: как доставлять данные в реальном времени

WebSocket против long polling, SSE и short polling: как работают, handshake и Sec-WebSocket-Accept, переподключение, масштабирование соединений, sticky sessions и pub/sub, что выбрать на интервью.

13 мин чтениясистемный дизайн · websocket · сети · javascript

Коротко

ПодходНаправлениеТранспортЗадержкаКогда брать
Short pollingклиент спрашиваетобычные HTTP-запросыдо интервала опросаредкие обновления, статус задачи, MVP
Long pollingсервер → клиентHTTP-запрос висит до событияпочти мгновенностарые клиенты и прокси, фолбэк
SSEсервер → клиентодин долгий HTTP-ответмгновенноленты, уведомления, прогресс, стриминг ответа LLM
WebSocketв обе стороныTCP после HTTP Upgradeмгновенночаты, игры, совместное редактирование, котировки
  • Главный вопрос — кто и как часто говорит. Если данные идут только от сервера, SSE почти всегда проще WebSocket.
  • Все долгие соединения дорого масштабировать: каждое держит память и файловый дескриптор, а сервер становится stateful.
  • Сообщение для клиента на другом сервере доставляют через pub/sub (Redis, Kafka, NATS), а не через sticky sessions.
  • Переподключение — часть протокола, а не исключение: клиенты отваливаются постоянно, особенно мобильные.

Задача

Пользователь открыл чат, ленту заказов или биржевой стакан. На сервере происходит событие — новое сообщение, смена статуса, сделка. Как сообщить об этом браузеру?

Классический HTTP устроен как «запрос — ответ»: говорить первым умеет только клиент. Все четыре подхода — разные способы обойти это ограничение. Разберём их от простого к сложному.

Short polling

Клиент раз в N секунд спрашивает: «есть что-нибудь новое?».

setInterval(async () => { const res = await fetch('/api/orders/42/status') render(await res.json()) }, 5000)

Плюсы: работает везде, сервер stateless, кэшируется, балансируется любым балансировщиком, отлаживается в DevTools.

Минусы считаются арифметикой. 100 000 клиентов с интервалом 5 секунд — это 20 000 запросов в секунду, и подавляющее большинство отвечает «ничего нового». Каждый запрос тащит заголовки, cookie, проверку авторизации. А задержка доставки — в среднем половина интервала. Уменьшишь интервал — вырастет нагрузка.

Short polling — нормальный выбор, когда обновления редкие и задержка в несколько секунд никого не беспокоит: статус генерации отчёта, проверка оплаты, дашборд, который обновляется раз в минуту.

Long polling

Клиент шлёт запрос, а сервер не отвечает, пока не появится событие или не истечёт таймаут. Получив ответ, клиент сразу отправляет следующий запрос.

Сервер на Node.js без зависимостей:

import http from 'node:http' const messages = [] // { id, text } const waiting = new Set() // висящие long-poll запросы function publish(text) { const msg = { id: messages.length + 1, text } messages.push(msg) for (const res of waiting) res.end(JSON.stringify([msg])) waiting.clear() } http .createServer((req, res) => { const url = new URL(req.url, 'http://localhost') if (url.pathname === '/poll') { const after = Number(url.searchParams.get('after') ?? 0) const fresh = messages.filter((m) => m.id > after) if (fresh.length > 0) return res.end(JSON.stringify(fresh)) // есть новое — сразу waiting.add(res) // нет — держим запрос const timer = setTimeout(() => { waiting.delete(res) res.end('[]') // таймаут: пустой ответ, клиент переподключится }, 25_000) req.on('close', () => { clearTimeout(timer) waiting.delete(res) }) return } if (url.pathname === '/send' && req.method === 'POST') { let body = '' req.on('data', (chunk) => (body += chunk)) req.on('end', () => { publish(body) res.end('ok') }) return } res.statusCode = 404 res.end() }) .listen(8080)

Клиент:

async function longPoll() { let after = 0 while (true) { try { const res = await fetch(`/poll?after=${after}`) for (const msg of await res.json()) { console.log('получено:', msg.text) after = msg.id } } catch { await new Promise((r) => setTimeout(r, 1000)) // сервер недоступен — пауза } } } longPoll()

Ключевая деталь — параметр after. Между двумя запросами есть окно, когда клиент ничем не подписан. Если сообщение придёт в это окно, без курсора оно потеряется. С курсором следующий запрос сразу заберёт всё пропущенное.

Таймаут 25 секунд выбран не случайно: многие прокси и балансировщики рвут неактивные соединения через 30–60 секунд (у nginx proxy_read_timeout по умолчанию 60 секунд). Лучше закрыть запрос самим и переподключиться, чем получить 504 от промежуточного узла.

Плюсы long polling: почти мгновенная доставка, обычный HTTP, проходит через любые корпоративные прокси. Минусы: на каждое событие — новый запрос с заголовками; при частых событиях клиент постоянно переподключается; сервер держит висящие запросы и должен хранить буфер сообщений для курсора. Сегодня long polling — в основном фолбэк. Например, Socket.IO по умолчанию начинает с long polling и затем пытается перейти на WebSocket.

Server-Sent Events

SSE — стандартный способ стримить события от сервера по одному долгому HTTP-ответу. Сервер отвечает с Content-Type: text/event-stream и не закрывает ответ, а дописывает в него события в простом текстовом формате:

id: 17 event: order-status data: {"orderId": 42, "status": "shipped"}

Каждое событие заканчивается пустой строкой. Поле id — главное преимущество SSE: при обрыве браузер сам переподключится и пришлёт заголовок Last-Event-ID: 17, а сервер дошлёт пропущенное. Поле retry: задаёт паузу перед переподключением в миллисекундах.

Обработчик для того же сервера:

const streams = new Set() // открытые SSE-соединения // в publish(): for (const res of streams) res.write(`id: ${msg.id}\ndata: ${JSON.stringify(msg)}\n\n`) if (url.pathname === '/events') { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', }) const lastId = Number(req.headers['last-event-id'] ?? 0) for (const m of messages.filter((m) => m.id > lastId)) { res.write(`id: ${m.id}\ndata: ${JSON.stringify(m)}\n\n`) } streams.add(res) req.on('close', () => streams.delete(res)) return }

В браузере — встроенный EventSource:

const source = new EventSource('/events') source.onmessage = (event) => render(JSON.parse(event.data)) source.addEventListener('order-status', (event) => updateOrder(JSON.parse(event.data)) )

Ограничения SSE, о которых стоит помнить:

  • Только сервер → клиент. Действия клиента идут обычными POST-запросами. Для большинства «живых» интерфейсов этого достаточно.
  • Только текст (UTF-8). Бинарные данные — через base64 или отдельный запрос.
  • EventSource не умеет свои заголовки. Токен в Authorization не положить: остаются cookie (с withCredentials для другого домена) или параметр в URL. Если нужны заголовки, SSE читают через fetch и ReadableStream — так делают клиенты LLM API, которые стримят ответ по токенам.
  • Лимит соединений в HTTP/1.1. Браузер держит около шести соединений на домен, и каждая вкладка с SSE занимает одно. Через HTTP/2 события идут отдельным потоком в общем соединении, и проблема исчезает.
  • Буферизация в прокси. Если nginx или CDN буферизуют ответ, события придут пачкой. Для nginx помогает заголовок ответа X-Accel-Buffering: no или proxy_buffering off.

WebSocket

WebSocket (RFC 6455, 2011) — постоянный двунаправленный канал. Обе стороны в любой момент шлют сообщения — текстовые или бинарные — без накладных расходов HTTP на каждое.

Handshake

Соединение начинается как обычный HTTP/1.1 запрос:

GET /chat HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: https://example.com

Сервер соглашается:

HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

После ответа 101 HTTP заканчивается: по тому же TCP-соединению идут WebSocket-фреймы. Sec-WebSocket-Accept — это не защита, а доказательство, что сервер действительно понял протокол, а не какой-то кэш вернул старый ответ. Считается так:

import base64 import hashlib GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # фиксирован в RFC 6455 def accept(key: str) -> str: digest = hashlib.sha1((key + GUID).encode()).digest() return base64.b64encode(digest).decode() print(accept("dGhlIHNhbXBsZSBub25jZQ==")) # s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Что ещё полезно знать о протоколе:

  • Фреймы. У каждого есть opcode: текст, бинарные данные, close, ping, pong. Большие сообщения можно резать на фрагменты.
  • Маскирование. Фреймы от клиента к серверу обязательно маскируются случайным ключом, от сервера к клиенту — нет. Это защита от отравления кэша промежуточных прокси, а не шифрование. Шифрование — только wss://, то есть TLS.
  • Ping/pong. Управляющие фреймы для проверки, что соединение живо. Браузерный API их не показывает, их шлёт сервер или сам браузер в ответ.
  • Origin. Браузер присылает Origin, но политика CORS к WebSocket не применяется. Проверять Origin и авторизацию при handshake — обязанность сервера, иначе чужой сайт откроет соединение с cookie пользователя (cross-site WebSocket hijacking).
  • HTTP/2 и HTTP/3. Через Upgrade WebSocket работает только поверх HTTP/1.1. Для HTTP/2 есть RFC 8441, для HTTP/3 — RFC 9220, но поддержка в прокси и серверах неполная, поэтому на практике чаще всего видишь HTTP/1.1.

Сервер и клиент

Чат с рассылкой всем на Python (библиотека websockets):

import asyncio from websockets.asyncio.server import broadcast, serve from websockets.exceptions import ConnectionClosed clients = set() async def handler(ws): clients.add(ws) try: async for message in ws: # ждём сообщения от клиента broadcast(clients, message) # и рассылаем всем подключённым except ConnectionClosed: pass # клиент пропал без close-фрейма — обычное дело finally: clients.discard(ws) async def main(): # ping_interval: сервер шлёт ping раз в 20 с и закрывает # соединение, если pong не пришёл за ping_timeout async with serve(handler, "0.0.0.0", 8765, ping_interval=20, ping_timeout=20): await asyncio.Future() # работать вечно asyncio.run(main())

В отличие от EventSource, браузерный WebSocket сам не переподключается. Это пишут руками:

function connect(url, onMessage, attempt = 0) { const ws = new WebSocket(url) ws.onopen = () => { attempt = 0 // успешно подключились — сбрасываем счётчик ws.send(JSON.stringify({ type: 'hello' })) } ws.onmessage = (event) => onMessage(JSON.parse(event.data)) ws.onclose = () => { // экспоненциальная пауза с jitter, максимум 30 секунд const delay = Math.random() * Math.min(30_000, 1000 * 2 ** attempt) setTimeout(() => connect(url, onMessage, attempt + 1), delay) } } connect('wss://example.com/chat', (msg) => console.log('получено:', msg))

Jitter здесь не для красоты. При деплое или падении сервера отключаются все клиенты разом. Если все переподключатся через ровно 1, 2, 4 секунды, они придут синхронной волной и положат только что поднявшийся сервер — это thundering herd.

WebSocket — голый транспорт. Формат сообщений, подтверждения доставки, номера последовательности для дозапроса пропущенного, авторизацию и истечение токена в середине сессии придётся спроектировать самому. Либо взять готовый слой: Socket.IO, SignalR, Centrifugo, протокол graphql-ws.

Масштабирование долгих соединений

С short polling сервер stateless: любой запрос может обработать любой экземпляр. Long polling, SSE и WebSocket делают сервер stateful — соединение живёт на конкретной машине. Отсюда три проблемы.

Сколько соединений держит сервер

Простаивающее соединение почти не тратит CPU, но тратит память (буферы ядра, TLS-сессия, объекты в приложении) и файловый дескриптор. Асинхронный сервер на Node.js, Go, Netty или asyncio держит десятки и сотни тысяч соединений на машину; поток на соединение так не масштабируется — это та самая проблема C10K.

Что упирается первым:

  • Лимит дескрипторов — ulimit -n и fs.file-max по умолчанию малы для такой нагрузки.
  • Память на буферы и состояние каждого клиента.
  • Порты между балансировщиком и бэкендом. Соединение определяется четвёркой «IP и порт источника, IP и порт назначения». Если балансировщик ходит в один бэкенд с одного IP, эфемерных портов хватит примерно на 28–64 тысячи соединений к одному адресу и порту. Лечится несколькими IP на балансировщике или несколькими портами на бэкенде.
  • Широковещательная рассылка. Сообщение в канал на 50 000 подписчиков — это 50 000 записей в сокеты, и они должны идти асинхронно, не блокируя остальную работу.

Балансировка и sticky sessions

WebSocket-соединение после handshake — это один долгий TCP-поток, и балансировщик просто держит его открытым до конца. Поэтому «прилипание» к серверу для самого WebSocket получается автоматически. Для nginx нужно явно пробросить заголовки апгрейда и увеличить таймаут:

map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { location /ws/ { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 3600s; # иначе соединение без трафика закроется через 60 с } }

Даже с длинным таймаутом шли ping не реже, чем раз в 30–60 секунд. Облачные балансировщики (AWS ALB по умолчанию — 60 секунд простоя), NAT мобильных операторов и корпоративные прокси молча рвут тихие соединения.

Sticky sessions нужны там, где одна логическая сессия состоит из многих HTTP-запросов, а состояние хранится в памяти конкретного процесса. Классика — Socket.IO с long polling: handshake пришёл на сервер A, а следующий poll-запрос на сервер B, который о сессии ничего не знает. Привязку делают по cookie или хешу IP (ip_hash в nginx). Подробнее про алгоритмы и минусы привязки — в статье о балансировке нагрузки.

Минусы sticky sessions стоит назвать на собеседовании: нагрузка распределяется неравномерно, при падении сервера все его клиенты разом переезжают, а хеш по IP ломается за NAT, где тысячи пользователей выходят с одного адреса. Если транспорт только WebSocket, привязка по cookie обычно не нужна.

Сообщение для клиента на другом сервере

Главная архитектурная задача. Пользователь Alice подключена к серверу A, Bob — к серверу B. Alice пишет Bob, сообщение приходит на A. Как доставить его Bob?

Типовое решение — слой pub/sub между серверами соединений:

  1. Сервер соединений при подключении клиента подписывается на его каналы: user:bob, chat:123.
  2. Сервис, обработавший сообщение, сохраняет его в базу и публикует в канал chat:123.
  3. Все серверы, у которых есть подписчики этого канала, получают событие и пишут его в свои сокеты.

Брокером служат Redis Pub/Sub или Streams, NATS, Kafka (чем отличаются гарантии — в статье про очереди сообщений). Альтернатива — реестр «пользователь → сервер» и прямая адресная отправка. При миллионах каналов пригодится консистентное хеширование, чтобы распределить их по узлам брокера.

Хорошая практика — выделить серверы соединений в отдельный тонкий слой. Они только держат сокеты, проверяют токен и маршрутизируют сообщения, а бизнес-логика живёт в обычных stateless-сервисах за ними. Тогда деплой бизнес-логики не рвёт миллионы соединений.

Деплой и надёжность доставки

  • Graceful shutdown. Перед остановкой сервер перестаёт принимать новые соединения и закрывает существующие постепенно, с кодом закрытия, чтобы клиенты переподключились на другие узлы не одновременно.
  • Pub/sub — не хранилище. Если клиент был офлайн три секунды, Redis Pub/Sub ему ничего не сохранит. Сообщения пишут в базу, у каждого есть монотонный id, и при переподключении клиент запрашивает «всё после id N» — как Last-Event-ID в SSE или курсор after в long polling.
  • Дубли неизбежны. Переподключение плюс дозапрос дают повторы, поэтому клиент дедуплицирует по id сообщения. Это та же история, что в статье об идемпотентности.
  • Ограничение частоты. Один клиент не должен заваливать сервер сообщениями — пригодится rate limiter на уровне соединения.

Как выбирать

  • Обновления раз в минуту, задержка не критична → short polling. Проще не бывает.
  • Сервер пушит события, клиент в основном читает: уведомления, лента, статус заказа, прогресс задачи, токены ответа LLM → SSE. Обычный HTTP, авто-переподключение и Last-Event-ID из коробки.
  • Частый двусторонний обмен с низкой задержкой: чат с индикатором «печатает», мультиплеер, совместное редактирование, торговый терминал → WebSocket.
  • Нужно пройти через старые прокси или поддержать древних клиентов → long polling как фолбэк.
  • Сервис-сервис внутри бэкенда → обычно не WebSocket, а gRPC-стриминг или брокер сообщений. Сравнение — в статье REST, gRPC или GraphQL.

Мобильные приложения в фоне держать соединение не могут: ОС его убьёт. Для доставки, когда приложение закрыто, нужны push-уведомления (APNs, FCM), а сокет — только пока приложение на экране.

Вопросы на собеседовании

  • «Чем long polling отличается от short polling?» — Short polling опрашивает по таймеру и чаще всего получает пустой ответ. Long polling держит запрос до события, задержка почти нулевая, пустых ответов мало.
  • «Как устанавливается WebSocket-соединение?» — HTTP/1.1 GET с Upgrade: websocket и Sec-WebSocket-Key, ответ 101 Switching Protocols с Sec-WebSocket-Accept = base64(SHA-1(key + GUID)), дальше фреймы по тому же TCP.
  • «Почему не WebSocket везде?» — Stateful-серверы, сложнее балансировка и деплой, своё переподключение и протокол поверх, хуже кэширование и наблюдаемость. Для однонаправленного потока SSE дешевле.
  • «Чем SSE хуже WebSocket?» — Только сервер → клиент, только текст, нет своих заголовков у EventSource, лимит соединений в HTTP/1.1.
  • «Как масштабировать чат на миллион онлайн-пользователей?» — Слой серверов соединений, pub/sub между ними, хранение сообщений с id и дозапрос пропущенного, heartbeat, graceful shutdown, jitter при переподключении.
  • «Нужны ли sticky sessions для WebSocket?» — Само соединение привязано к серверу, пока живо. Sticky нужны для протоколов из многих HTTP-запросов (long polling в Socket.IO) или когда состояние сессии лежит в памяти. Лучше вынести состояние наружу.
  • «Как понять, что соединение умерло?» — TCP может не заметить обрыв долго. Нужен heartbeat: ping/pong с таймаутом на сервере и, при необходимости, прикладной ping от клиента.
  • «Как авторизовать WebSocket?» — При handshake: cookie с проверкой Origin или короткоживущий токен в первом сообщении либо в URL. Плюс план на истечение токена в открытом соединении.

Если задача на интервью — «спроектируйте чат» или «ленту уведомлений», транспорт обсуждают минут пять. Назови вариант, объясни, почему не остальные, и переходи к доставке между серверами и хранению — именно там интервьюер ищет глубину. Общий план ответа описан в статье о подходах к system design.

Итог

Short polling тратит запросы на пустые ответы, long polling экономит их, но переподключается после каждого события, SSE даёт дешёвый поток от сервера поверх обычного HTTP, WebSocket — полноценный двусторонний канал ценой stateful-инфраструктуры. Выбор начинается с вопроса «кто говорит и как часто», а сложность начинается после выбора: переподключение с jitter, heartbeat, pub/sub между серверами, дозапрос пропущенного по id и аккуратный деплой. Кто может рассказать об этом на собеседовании, показывает, что эксплуатировал реальный realtime, а не только читал про new WebSocket().

Потренироваться в спокойной обстановке

Все задачи