SSE — это не просто «стриминг»: что превращает поток данных в надёжный realtime-сервис

Когда продуктовая команда объявляет, что чат или лента уведомлений работает «в реальном времени», за этим чаще всего стоит одно из трёх: WebSocket, бесконечный polling или Server-Sent Events. Все три звучат примерно одинаково убедительно в презентации. На практике же разница между ними — и особенно разница между правильно реализованным SSE и наивным "fetch"-потоком — проявляется именно тогда, когда сеть ведёт себя как сеть: обрывается, буферизируется прокси или переключается с Wi-Fi на мобильный интернет в самый неподходящий момент.

Схема потока данных: надёжный SSE для realtime-сервиса с восстановлением соединения и повторной отправкой событий

Три уровня, которые часто путают

Чтобы понять, где именно возникает надёжность, полезно разделить «потоковую передачу данных» на три уровня.

Сырой fetch-поток — это просто чтение response.body через ReadableStream. Данные приходят кусками (chunks), вы обрабатываете их по мере поступления. Никакой структуры, никакого протокола, никакого автоматического восстановления. Если соединение прервалось, вы об этом узнаете только по исключению — и то не всегда сразу.

SSE по спецификации — это формат поверх HTTP, где каждое сообщение закодировано в text/event-stream с полями data, event, id и retry, а события разделяются пустой строкой. Это не отдельный протокол — это обычный HTTP-ответ с длинным телом и конкретным MIME-типом. Благодаря этому SSE проходит через балансировщики нагрузки и прокси без специальной конфигурации.

Браузерный EventSource — это встроенный интерфейс, который реализует SSE «под капотом»: устанавливает соединение, парсит поток, отправляет Last-Event-ID при переподключении и автоматически восстанавливает связь. Он удобен, но имеет принципиальные ограничения: не поддерживает кастомные заголовки и POST-запросы как стандартный браузерный режим, а управление поведением повторных подключений ограничено тем, что позволяет спецификация.

Вопрос не в том, какой уровень «лучше». Вопрос в том, какой уровень вы фактически реализовали — и насколько аккуратно.

Что ломается в стриминге и чем это лечится

Ниже — сводка типичных точек отказа и механизмов защиты от них:

Проблема Источник сбоя Механизм защиты
Обрыв соединения Сеть, прокси, таймаут сервера Ретрай с backoff; Last-Event-ID для возобновления
Буферизация прокси HTTP-прокси, накапливающий чанки Transfer-Encoding: chunked; keep-alive комментарии в потоке
Незавершённые чанки Разбивка сетевого пакета посреди строки Буферизация хвоста; обработка только полных строк
Потеря позиции в потоке Клиент не отслеживает id Отправка Last-Event-ID при переподключении
Ложный ретрай при отмене AbortSignal приходит в блок catch Проверка signal.aborted до решения о ретрае
Ошибки парсинга SSE Нулевой байт в id, многострочный data Строгое следование спецификации поле за полем

Каждая строка этой таблицы — это реальная точка, где демо-реализация и продакшен-реализация расходятся.

Почему парсинг SSE сложнее, чем выглядит

Формат text/event-stream кажется тривиальным: строки, двоеточие, пустая строка — конец события. Но дьявол в деталях, которые спецификация описывает явно, а большинство самодельных клиентов игнорирует.

Первая ловушка — границы чанков не совпадают с границами строк. Один вызов reader.read() может вернуть данные, которые обрываются посреди строки. Если вы разбиваете полученную строку по \n и сразу обрабатываете результат — вы гарантированно получите битые события. Правильное решение: держать в буфере последнюю незавершённую строку и дописывать к ней следующий чанк.

Вторая ловушка — поле data может быть многострочным. Несколько строк data: подряд — это одно событие, где значения соединяются символом перевода строки. Игнорировать эту деталь означает либо потерять данные, либо склеить их неправильно.

Третья ловушка — нулевой байт в поле id. Спецификация явно требует отбрасывать значение id, если оно содержит символ \0. Это ограничение существует не случайно: оно защищает от некорректного состояния клиента. Почти ни одна наспех написанная реализация не проверяет это условие.

Четвёртая ловушка — пустая строка завершает событие, а не файл. Если поток закрывается посреди события — без финальной пустой строки — незавершённое событие должно быть отброшено, а не передано приложению. Это означает, что буферизация не только нужна для чанков, но и является частью корректной семантики формата.

flowchart TD
 A[Получен чанк данных] --> B[Добавить к буферу]
 B --> C[Разбить на строки]
 C --> D{Последняя строка полная?}
 D -- Нет --> E[Оставить в буфере]
 D -- Да --> F[Обработать все строки]
 F --> G{Пустая строка?}
 G -- Да --> H[Сформировать событие и yield]
 G -- Нет --> I[Разобрать поле: data / event / id / retry]
 I --> F

Last-Event-ID: договор, а не магия

Автоматическое восстановление соединения — одна из самых часто упоминаемых возможностей SSE. Но важно понимать, что именно оно гарантирует, а что нет.

Браузерный EventSource действительно переподключается автоматически и отправляет Last-Event-ID в заголовке запроса. Специфицированная задержка перед переподключением составляет несколько секунд и может быть скорректирована сервером через поле retry. Это полезно — но только если сервер умеет принимать этот заголовок и возобновлять поток с нужной позиции.

Клиент отслеживает последний полученный id и передаёт его при переподключении. Это необходимое условие, но недостаточное. Сервер должен хранить историю событий и уметь «перемотать» поток назад. Если сервер просто начинает стрим заново — Last-Event-ID ничему не помогает. Восстановление позиции — это контракт между клиентом и сервером, и наивно считать его «встроенной гарантией».

Кроме того, автоматическое переподключение не означает отсутствие потерь: если сервер не реализует воспроизведение пропущенных событий, они будут потеряны независимо от корректности клиента.

Отмена против ошибки: принципиальная разница

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

Когда пользователь покидает страницу или компонент размонтируется, правильное поведение — остановить стрим через AbortController. Но если блок catch просто перехватывает любое исключение и запускает ретрай — он будет пытаться переподключиться даже после явной отмены.

Проверка signal?.aborted перед принятием решения о повторной попытке — это небольшая строчка кода, которая предотвращает класс трудноуловимых багов: обновление состояния размонтированного компонента, утечки соединений, неожиданная нагрузка на сервер.

Когда встроенного EventSource недостаточно

Стандартный браузерный EventSource покрывает большинство базовых случаев: GET-запрос, без кастомных заголовков, с автоматическим переподключением. Но реальные продукты часто требуют большего.

AI-чат, как правило, использует POST для отправки запроса вместе с контекстом разговора. Аутентификация через заголовок Authorization — стандартная практика. Управление интервалом между попытками подключения в зависимости от состояния приложения — распространённое требование. Ни одно из этих сценариев не поддерживается стандартным EventSource.

В таких случаях ручная реализация клиента на основе fetch становится не прихотью, а необходимостью. Но это означает, что весь объём работы по парсингу, буферизации, трекингу id и различению ошибок и отмены ложится на разработчика.

Что «realtime» означает в реальности

Слово «realtime» в описании продукта почти всегда означает что-то конкретное на уровне инфраструктуры — и это конкретное часто скрыто за маркетинговым обещанием.

SSE хорошо подходит для односторонней доставки событий: уведомлений, потоковых ответов языковых моделей, обновлений статуса. Он работает через стандартный HTTP, не требует специального серверного стека и экономно расходует память на стороне браузера — в отличие от XHR, который буферизирует весь ответ. Но SSE — это не универсальное решение: для двустороннего общения нужны WebSocket или другие механизмы, а для бинарных данных SSE неэффективен.

Надёжность стриминга определяется не тем, какой протокол указан в readme или в питче инвесторам. Она определяется тем, насколько аккуратно реализованы три вещи: корректный парсинг формата событий по спецификации, восстановление соединения через Last-Event-ID как совместный механизм клиента и сервера, и чёткое разграничение между сетевой ошибкой и намеренной отменой.

Демо работает у всех. Держаться при плохой сети, через корпоративный прокси, на мобильном соединении — это уже инженерный вопрос, а не вопрос выбора красивого термина.

Источники

  1. HTML
  2. How to Build a Reliable SSE Client in TypeScript
  3. Browser APIs and Protocols: Server-Sent Events (SSE) – High Performance Browser Networking (O’Reilly)
Поделиться:
Telegram Facebook X VK
Scroll to Top