WebSocket, long polling или SSE: как доставлять данные в реальном времени
WebSocket против long polling, SSE и short polling: как работают, handshake и Sec-WebSocket-Accept, переподключение, масштабирование соединений, sticky sessions и pub/sub, что выбрать на интервью.
Коротко
| Подход | Направление | Транспорт | Задержка | Когда брать |
|---|---|---|---|---|
| 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. Через
UpgradeWebSocket работает только поверх 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 между серверами соединений:
- Сервер соединений при подключении клиента подписывается на его каналы:
user:bob,chat:123. - Сервис, обработавший сообщение, сохраняет его в базу и публикует в канал
chat:123. - Все серверы, у которых есть подписчики этого канала, получают событие и пишут его в свои сокеты.
Брокером служат 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().