Навигация

Server-Sent Events: real-time через HTTP

Server-Sent Events (SSE) — технология доставки обновлений от сервера к клиенту через обычный HTTP. Сервер открывает одно соединение и постепенно отправляет события, а браузер переподключается автоматически. Разберём формат событий, EventSource API, реализацию в React и Next.js, ограничения и сравнение с WebSocket.

Содержание

  1. Что такое SSE
  2. Формат события
  3. EventSource API
  4. Именованные события
  5. Серверная часть на Node.js
  6. Реализация в React
  7. SSE в Next.js
  8. Авто-переподключение и Last-Event-ID
  9. Обработка ошибок
  10. CORS и авторизация
  11. Ограничения и масштабирование
  12. SSE vs WebSocket vs Long Polling
  13. Лучшие практики
  14. Антипаттерны

Что такое SSE

Server-Sent Events — механизм, при котором сервер открывает одно HTTP-соединение и отправляет события клиенту по мере их появления. Клиент слушает поток через EventSource.

Ключевые свойства:

  • Односторонняя передача: только сервер → клиент.
  • Обычный HTTP: никакого нового протокола, как у WebSocket.
  • Автоматическое переподключение: браузер сам восстанавливает оборванное соединение.
  • Текстовый формат: передача UTF-8 строк, без бинарных данных.

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


Формат события

Ответ сервера идёт с заголовком Content-Type: text/event-stream. Тело — последовательность блоков, разделённых пустой строкой. Каждый блок состоит из строк-полей:

ПолеНазначение
data:Тело события (может быть несколько строк — они склеиваются через \n).
event:Имя события для addEventListener. По умолчанию — message.
id:Идентификатор события, отправляется в Last-Event-ID при переподключении.
retry:Задержка переподключения в миллисекундах.
data: {"message": "Первый пошёл"}

data: {"message": "Второй"}
id: 42

event: order-status
data: {"orderId": "123", "status": "shipped"}

Строка без двоеточия или с : в начале — комментарий и игнорируется; это удобно для heartbeat-запросов: : ping.


EventSource API

EventSource — нативный браузерный API, наследник EventTarget. Никаких библиотек не нужно.

const source = new EventSource('/api/events');

source.onopen = () => console.log('Соединение открыто');
source.onmessage = (event) => {
  const data = JSON.parse(event.data);
  console.log('Событие:', data);
};
source.onerror = () => console.log('Ошибка соединения');

// Закрыть вручную
source.close();

Свойства:

  • readyState — CONNECTING (0), OPEN (1), CLOSED (2).
  • url — URL потока.
  • withCredentials — передавать ли cookies при кросс-доменных запросах.

В отличие от WebSocket, у EventSource нет стадии рукопожатия и методов send — соединение всегда инициирует клиент, а данные только принимаются.


Именованные события

Когда сервер шлёт поле event:, событие получает имя. Обработчик onmessage на него не сработает — нужен addEventListener.

const source = new EventSource('/api/events');

source.addEventListener('order-status', (event) => {
  const data = JSON.parse(event.data);
  console.log('Статус заказа:', data);
});

source.addEventListener('user-offline', (event) => {
  console.log('Пользователь офлайн');
});

Полезно, когда в одном потоке идут разные типы событий: уведомления, изменение статуса, heartbeat.


Серверная часть на Node.js

SSE требует сервера, который умеет держать соединение открытым и писать в поток. Простейший пример на Express:

const express = require('express');
const app = express();

app.get('/api/events', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive',
  });

  const send = (data, id) => {
    res.write(`id: ${id}\n`);
    res.write(`data: ${JSON.stringify(data)}\n\n`);
  };

  let counter = 0;
  const interval = setInterval(() => {
    send({ time: Date.now() }, ++counter);
  }, 1000);

  // Закрыть соединение при обрыве клиента
  req.on('close', () => {
    clearInterval(interval);
  });
});

app.listen(3000);

Важные детали:

  • Cache-Control: no-cache — прокси не должны кэшировать поток.
  • Connection: keep-alive — держать соединение.
  • Очистка ресурсов по req.on('close') — иначе интервалы утекают.
  • Heartbeat — периодический комментарий : ping или пустое событие, чтобы соединение не убивалось прокси по таймауту бездействия.

Реализация в React

EventSource — клиентская концепция, поэтому создаётся в useEffect, а закрывается в cleanup.

import { useEffect, useRef, useState } from 'react';

function useEventSource(url) {
  const [messages, setMessages] = useState([]);
  const sourceRef = useRef(null);

  useEffect(() => {
    const source = new EventSource(url);
    sourceRef.current = source;

    source.onmessage = (event) => {
      const data = JSON.parse(event.data);
      setMessages((prev) => [...prev, data]);
    };

    source.onerror = () => {
      // EventSource сам переподключится
      console.log('SSE connection lost');
    };

    return () => {
      source.close();
      sourceRef.current = null;
    };
  }, [url]);

  const close = () => sourceRef.current?.close();

  return { messages, close };
}

Приостановка при скрытии вкладки

Как и в long polling, не стоит держать поток в фоне:

useEffect(() => {
  const source = new EventSource(url);

  const handleVisibility = () => {
    if (document.hidden) {
      source.close();
    } else {
      // Пересоздать поток
    }
  };

  document.addEventListener('visibilitychange', handleVisibility);
  return () => {
    source.close();
    document.removeEventListener('visibilitychange', handleVisibility);
  };
}, [url]);

SSE в Next.js

В App Router поток реализуется через Route Handler: серверный код держит ReadableStream и пишет в него события, а клиент читает их через fetch или EventSource.

// app/api/events/route.ts
export async function GET() {
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    start(controller) {
      const send = (data: string) => {
        controller.enqueue(encoder.encode(`data: ${data}\n\n`));
      };

      send(JSON.stringify({ message: 'Поток открыт' }));

      const interval = setInterval(() => {
        send(JSON.stringify({ time: Date.now() }));
      }, 1000);

      // req.signal срабатывает при отмене/обрыве
      // здесь — упрощённо, через обработку сигнала
    },
    cancel() {
      // клиент закрыл соединение — очистить ресурсы
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
}

Особенности Next.js:

  • На Vercel долгоживущие потоки не работают — serverless-функции ограничены по времени выполнения. SSE нужно деплоить на Node.js-хостинг (самостоятельный сервер, Fly.io, Railway) или использовать edge-платформы, поддерживающие стриминг.
  • Route Handler может слушать req.signal через request.signal из аргумента, чтобы корректно закрыть поток при отмене запроса клиентом.

Авто-переподключение и Last-Event-ID

Главное преимущество SSE перед WebSocket — переподключение встроено в браузер. При обрыве EventSource автоматически пытается восстановить соединение.

Если сервер отправлял id: для событий, браузер при переподключении передаёт заголовок Last-Event-ID с последним полученным id. Сервер может прочитать его и отправить пропущенные события — клиент не потеряет данные за время обрыва.

// Express: читаем Last-Event-ID
app.get('/api/events', (req, res) => {
  const lastEventId = req.headers['last-event-id'];
  // Отправить события с id > lastEventId, затем продолжить поток
});

Задержку повтора задаёт сервер через retry: или клиент — не задаёт вообще: у EventSource нет API для настройки backoff, в отличие от WebSocket, где задержку переподключения пишете вы сами. Если нужен свой алгоритм — close() и создание нового EventSource с собственной паузой.


Обработка ошибок

onerror срабатывает при обрыве соединения, сетевой ошибке и недоступности сервера. Важно понимать: EventSource переподключается сам, поэтому в обработчике обычно не нужно создавать новый источник.

source.onerror = (error) => {
  console.log('Состояние:', source.readyState);
  // CONNECTING — браузер уже пытается переподключиться
};

Если сервер отвечает не text/event-stream (например, 404 или 500), EventSource не бросает исключение, а молча переходит в CONNECTING и пробует снова — бесконечно. Чтобы остановить цикл, нужен close():

source.onerror = (error) => {
  // Сервер недоступен — прекратить попытки
  source.close();
  console.error('SSE failed');
};

Проверку успешного старта делайте в onopen — это единственная гарантия, что поток реально открылся.


CORS и авторизация

EventSource не позволяет управлять заголовками запроса — только cookies. Это ограничение важно для двух сценариев.

Кросс-доменные потоки

// Сервер должен отдавать CORS-заголовки
Access-Control-Allow-Origin: https://app.example.com

// И клиент передаёт cookies
const source = new EventSource('https://api.example.com/events', {
  withCredentials: true,
});

Авторизация через токен в заголовке

Токен в Authorization передать нельзя — EventSource не даёт установить заголовки. Обходные пути:

  • Cookie вместо Bearer-токена (удобно при withCredentials).
  • Прокси: свой сервер принимает авторизацию и пересылает запрос к SSE-эндпоинту.
  • fetch + ReadableStream: вместо EventSource читать поток через fetch, где заголовки можно задать любые:
const response = await fetch('/api/events', {
  headers: { Authorization: `Bearer ${token}` },
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  console.log(decoder.decode(value));
}

Но тогда теряется встроенное переподключение и Last-Event-ID — приходится реализовывать их вручную.


Ограничения и масштабирование

1. Ограничение на число соединений

HTTP/1.1 позволяет браузеру держать ~6 соединений на домен. Каждое SSE-соединение — одно из них. Несколько параллельных потоков с одного домена могут исчерпать лимит. Решения: разные поддомены, HTTP/2 (одно TCP-соединение на множество потоков) или консолидация потоков.

2. Проблемы с HTTP/2 и прокси

В HTTP/2 все потоки идут через одно TCP-соединение, и многие прокси буферизуют ответ целиком, прежде чем отдать его клиенту — стриминг ломается. Настраивайте прокси на отключение буферизации (X-Accel-Buffering: no для nginx) или зажимайте соединение.

3. Нагрузка на сервер

Каждый клиент — открытый HTTP-ответ, держащийся потенциально часами. При тысячах клиентов нужны асинхронные серверы, грамотный heartbeat и таймауты бездействия.

4. Только текст, только в одну сторону

Бинарные данные (аудио, изображения, большие payload) не передать напрямую — только base64. Двусторонний обмен — тоже нет. Для этих задач WebSocket.


SSE vs WebSocket vs Long Polling

КритерийSSEWebSocketLong Polling
ПротоколHTTPWebSocket (поверх TCP)HTTP
НаправлениеСервер → клиентДвустороннееСервер → клиент
Авто-переподключениеВстроеноРучная реализацияРучная реализация
Last-Event-ID (догонка событий)ДаНетНет
Бинарные данныеНет (только текст)ДаНет
Заголовки запросаНельзя задатьМожноМожно
Совместимость с прокси/CDNХорошаяМожет блокироватьсяХорошая
СложностьНизкаяВышеСредняя

Правило выбора:

Нужны real-time обновления?
├── Только сервер → клиент, текстовые данные → SSE (проще всего)
├── Двусторонняя связь или бинарные данные → WebSocket
└── SSE недоступен (старые браузеры, нет контроля заголовков) → Long Polling

Лучшие практики

1. Используй SSE для односторонних обновлений

Не тяни WebSocket туда, где данные идут только от сервера: уведомления, тикеры, логи.

2. Задавай id: каждому событию

Это даёт надёжное восстановление через Last-Event-ID при обрыве.

3. Держи heartbeat

Пустой комментарий : ping каждые 15–30 секунд не даёт прокси убить соединение по таймауту бездействия.

4. Чисти ресурсы в cleanup

В React — source.close() в cleanup useEffect, на сервере — очистка интервалов по req.on('close').

5. Останавливай поток на скрытой вкладке

visibilitychange экономит трафик и соединения.

EventSource не умеет заголовки — продумай auth до внедрения.


Антипаттерны

1. Бесконечный цикл переподключения при недоступном сервере

// ❌ Плохо: браузер будет переподключаться вечно
source.onerror = () => {};

// ✅ Хорошо: прекратить попытки, если сервер мёртв
source.onerror = () => source.close();

2. Парсинг JSON без try/catch

// ❌ Плохо: битое событие уронит обработчик
source.onmessage = (e) => setData(JSON.parse(e.data));

// ✅ Хорошо
source.onmessage = (e) => {
  try {
    setData(JSON.parse(e.data));
  } catch (err) {
    console.error('Invalid SSE payload', err);
  }
};

3. Создание EventSource на каждый рендер

// ❌ Плохо: соединение открывается и закрывается на каждом рендере
const source = new EventSource(url);

// ✅ Хорошо: в useEffect с cleanup
useEffect(() => {
  const source = new EventSource(url);
  return () => source.close();
}, [url]);

4. Запуск нескольких потоков с одного домена

Некоторые браузеры ограничивают число соединений на домен — не открывай десятки SSE-потоков; объединяй события в один поток с именованными событиями.

5. Ожидание «двусторонности»

Попытка отправить данные клиент → сервер через SSE невозможна — для этого нужен обычный HTTP-запрос или WebSocket.


Ключевые тезисы для интервью

  • SSE — односторонний real-time через обычный HTTP, без нового протокола.
  • Ответ идёт с Content-Type: text/event-stream, события разделяются пустой строкой.
  • Поля события: data, event, id, retry; несколько строк data: склеиваются в одно событие.
  • EventSource автоматически переподключается и передаёт Last-Event-ID при обрыве.
  • onmessage — только для безымянных событий; именованные обрабатывает addEventListener.
  • EventSource нельзя настроить заголовки запроса — только cookies (withCredentials).
  • В HTTP/1.1 ограничение ~6 соединений на домен, а HTTP/2 и прокси могут ломать стриминг из-за буферизации.
  • Каждое соединение держит открытый HTTP-ответ — при масштабировании нужны асинхронные серверы и heartbeat.
  • В Next.js долгоживущие SSE-потоки несовместимы с serverless-функциями Vercel.
  • Для двусторонней связи и бинарных данных — WebSocket; SSE — для потока сервер → клиент.

Заключение

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

Ключевое для Middle+ разработчика:

  • Понимать формат event stream и разницу data/event/id/retry.
  • Уметь корректно управлять жизненным циклом EventSource в React.
  • Знать ограничения: число соединений, буферизация прокси, отсутствие заголовков.
  • Чётко выбирать между SSE, WebSocket и long polling по направлению данных и требованиям.

Полезные ссылки