Server-Sent Events (SSE) — concept page. One-way server -> client streaming over plain HTTP using text/event-stream format. EventSource browser API with built-in auto-reconnect and Last-Event-ID resume. Three scenarios: (1) basic SSE setup and steady event delivery from a Redis Stream, (2) connection drop with auto-reconnect and Last-Event-ID replay of missed events, (3) LLM token streaming OpenAI/Anthropic-style with data: [DONE] terminator. Three ADRs covering SSE vs WebSocket vs long-poll choice, Redis Stream resume buffer strategy, and disabling proxy buffering plus compression on SSE endpoints.
Сервер открывает один HTTP-ответ и пишет в него
data: ...\n\nбесконечно. Браузер парсит, эмитит JS-события, сам реконнектится. Никаких новых протоколов поверх HTTP.
Половина «real-time» в продукте — это односторонний push: сервер знает что-то новое и хочет сказать клиенту. Прилетел новый заказ, изменился статус деплоя, LLM выдала следующий токен, обновились unread-counters в шапке. Клиент при этом ничего не пушит обратно — он просто слушает.
Под такой профиль трафика есть три инструмента:
SSE — четвёртый вариант, и для одностороннего push он почти
всегда правильный. Это обычный HTTP/1.1 chunked-ответ (или
HTTP/2 stream), который сервер не закрывает. Браузер парсит wire-format
через нативный EventSource API. Auto-reconnect с Last-Event-ID —
встроены. Работает через любой прокси, который не сломан в принципе.
Если в системе всё-таки появляется регулярный client → server трафик (чат с typing indicators, multiplayer, collaborative editing с позициями курсоров) — нужен WebSocket. Если нет — SSE короче, проще в эксплуатации и не плодит параллельный стек.
Три вещи, которые надо удерживать в голове:
GET /stream → 200 OK с заголовком
Content-Type: text/event-stream → сервер пишет в body байты и
не вызывает close(). Браузер читает по мере прихода.\n\n. Каждое событие —
набор полей data:, event:, id:, retry:, разделённых
\n. Конец события — пустая строка. Любая строка с : в начале —
комментарий (keep-alive).Last-Event-ID: <last> в заголовке
следующего запроса, сам ждёт retry: N миллисекунд. Сервер должен
уметь это пережить: иметь буфер последних событий и уметь начать
стрим не с now, а с указанного id.Кеш-мнемоника: «long-poll, у которого ответ длится вечно, плюс браузер сам знает, где остановился».
Скрипт раскладывает один минимально-полный SSE-стек на четыре группы:
EventSource. На ноде
закреплены три ADR (выбор транспорта, стратегия resume, гигиена
прокси)./stream) и Redis
Stream как буфер событий. Redis Stream здесь не «потому что
Redis», а потому что нужен durable bounded buffer с монотонными
id для replay по Last-Event-ID.Edges на диаграмме — физические провода: browser → nginx → sse → redis, api → redis, llm → sse. Ответы (фреймы серверу
→ клиенту) идут по тем же проводам в обратную сторону (reverse
animation на FlowBuilder), отдельные edges под «push» не нужны.
1) basic-stream — стандартный happy path.
Клиент делает new EventSource('/stream'). Браузер шлёт GET с
Accept: text/event-stream. NGINX проксирует БЕЗ буферизации.
Сервер отвечает 200 OK + Content-Type: text/event-stream +
Cache-Control: no-cache, no-transform — ответ остаётся открыт.
Параллельно API service делает XADD в Redis stream. SSE handler
крутит XREAD BLOCK 30000 (long-blocking read) — как только
прилетает новая запись, формирует SSE-фрейм (event: + id: +
data: + \n\n) и пишет в открытый ответ. NGINX мгновенно
форвардит — на клиенте срабатывает listener. Каждые 15-30 секунд
сервер шлёт : keepalive\n\n (comment line) — EventSource его
игнорирует, но TCP остаётся живым (не сработают idle timeouts).
Инвариант: один TCP, бесконечно событий, ноль polling.
Bandwidth = O(events), не O(time).
2) reconnect-resume — TCP drop в середине стрима.
Прилетели события 42 и 43, клиент запомнил lastEventId = "43".
Дальше — laptop sleep / Wi-Fi → LTE handoff / proxy reset →
TCP RST. Сервер при следующей записи получает EPIPE и закрывает
свой handler. Тем временем API публикует 44 и 45 в Redis (они
никуда не пропали — буфер их держит). Браузер видит обрыв,
ждёт retry миллисекунд (по умолчанию 3 секунды, сервер может
переопределить полем retry: N) и сам делает GET со
встроенным заголовком Last-Event-ID: 43. Никакого application
кода — это поведение EventSource. Handler читает заголовок,
делает XRANGE events.stream (43+1)-0 +, получает пропущенные
44 и 45, отправляет их фреймами с теми же id, потом возвращается
к нормальному XREAD BLOCK. Клиент догнал стрим, в коде
прикладного слоя ноль строк про reconnect. Гочa: если
Last-Event-ID уехал за пределы буфера (был 30-минутный outage,
а буфер хранит 15 минут) — отдать 410 Gone или фрейм
event: snapshot со ссылкой на REST-эндпоинт полного состояния.
3) llm-streaming — OpenAI/Anthropic-стиль.
Это де-факто стандарт для LLM API: POST с промптом, в ответ —
SSE-стрим токенов, финальный фрейм data: [DONE]. Деталь, которая
ломает EventSource: он умеет только GET и не умеет custom
headers (включая Authorization). Поэтому везде, где есть POST +
auth + streaming (то есть везде, где есть LLM API), используют
fetch + ReadableStream или полифилл @microsoft/fetch-event-source —
тот же wire format, та же логика, но через fetch. Сценарий
демонстрирует: prompt летит на LLM service, токены приходят по
одному, каждый — отдельный SSE-фрейм. UX-эффект: perceived
latency падает с «жди 5 секунд» до «первый байт через 200мс»,
текст «печатается» на глазах. Этим же паттерном работают
POST /v1/chat/completions?stream=true у OpenAI, Anthropic
Messages API с stream: true, Mistral, Cohere.
Полные ADR закреплены на ноде browser (вкладка Decisions). TL;DR:
ADR-001 — SSE vs WebSocket vs long-poll. SSE когда трафик server → client only и payload текстовый. WebSocket когда client → server тоже real-time (chat, games, collab editor, WebRTC signaling). Long-poll только как fallback для очень старых корпоративных прокси. Если вы открываете параллельный POST-endpoint, чтобы слать каждое нажатие клавиши с клиента — вам нужен WebSocket, не SSE.
ADR-002 — Resume стратегия.
Bounded server-side buffer (Redis Streams: XADD с auto-id +
XTRIM MAXLEN ~ N), монотонные глобально упорядоченные id
(stream id вида timestamp-seq или Snowflake — НЕ autoincrement-
per-pod, оно сломается через два инстанса), окно 5-15 минут или
10K-100K событий. При reconnect за пределы окна — 410 Gone +
snapshot. SLA задокументировать: «replayable up to N минут,
older reconnects = full state refetch».
ADR-003 — Гигиена прокси.
Это то, на чём ломаются 90% первых деплоев SSE.
proxy_buffering off в NGINX location block. gzip off или
Content-Encoding: identity (gzip буферизует до 16KB+, ломает
streaming feel и его обычно списывают на «сеть»).
Cache-Control: no-cache, no-transform — no-transform мешает
mid-path прокси (корпораты, мобильные операторы) пере-сжимать
ответ. ALB idle timeout ≥ keep-alive period + margin (стандартные
60 секунд ок, если пинговать каждые 30). Heartbeat :\n\n каждые
15-30 секунд — невидим для приложения, держит TCP. Проверять
сразу curl --no-buffer -N <url> — байты должны капать, а не
вывалиться пачкой.
fetch+ReadableStream,
потому что EventSource не умеет POST).live-events для нотификаций.proxy_buffering off. События накапливаются,
латентность взлетает, клиент видит пачки вместо потока.Last-Event-ID игнорируется. Сервер стримит «с now», после
reconnect клиент молча пропускает события навсегда.EventSource. Не работает — EventSource
не умеет custom headers. Либо cookie-based auth, либо
fetch-EventSource полифилл.Last-Event-ID теряет смысл. Глобально
монотонные id (Redis stream id, Snowflake) — обязательно./concepts/websocket) — когда нужно полноценное
bidirectional. Сравнение по latency, payload, операционке.XADD, XREAD BLOCK, XRANGE, XTRIM.
Канонический backend для bounded event buffer.EventSource.proxy_buffering docs — точная семантика и где её
выключать.[DONE] стал индустриальным sentinel'ом.