SSE приходит одним куском вместо потока

События Server-Sent Events прилетают пачкой вместо потока. Их держат три слоя: proxy_buffering, gzip и само приложение. Как проверить каждый.

Что это значит

Сервер пишет события по одному. Браузер тридцать секунд не получает ничего, а потом сразу выдаёт двадцать message. На тот же адрес curl -N показывает ровный поток, значит байты из приложения выходят, а копят их между приложением и браузером. Почти всегда копит nginx.

Как исправить

Поток держат три слоя. Проверяйте их в таком порядке.

Слой 1: nginx. proxy_buffering включён по умолчанию. nginx забирает ответ целиком, освобождает бэкенд и отдаёт клиенту в своём темпе. Для потока это ровно то, чего быть не должно:

location /events {
    proxy_pass http://app;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    proxy_buffering off;
    proxy_cache off;
    chunked_transfer_encoding on;
    proxy_read_timeout 1h;
}

По строкам:

  • proxy_buffering off и есть то самое исправление. Каждый кусок от бэкенда уходит клиенту сразу.
  • proxy_cache off стоит и так по умолчанию, но кэш, унаследованный от родительского блока, собрал бы поток целиком. Лучше написать явно.
  • chunked_transfer_encoding on повторяет значение по умолчанию, и его надо оставить. В скопированных конфигах часто стоит off, и это ломает SSE: у потока нет Content-Length, а границы кускам задаёт именно chunked-кодирование.
  • proxy_read_timeout по умолчанию равен 60s. Поток, который замолчал, обрывается на минуте. Это отдельная причина.
  • proxy_http_version 1.1 вместе с proxy_set_header Connection '' лечит другое. nginx по умолчанию ставит бэкенду Connection: close, а старые версии проксируют по HTTP/1.0. Эти две строки дают пулу upstream … keepalive переиспользовать соединение, то есть ту самую постоянную связь из RFC 9112, пункт 9.3. На буферизацию они не влияют.

Конфиг править нельзя? Отправьте заголовок из приложения:

X-Accel-Buffering: no

nginx его читает и выключает буферизацию для этого ответа. Так переносимее, потому что заголовок едет вместе с кодом. Работает, только если прокси действительно nginx и заголовок не выброшен через proxy_ignore_headers.

Слой 2: сжатие (Content-Encoding). nginx жмёт только типы из gzip_types, а по умолчанию там один text/html, так что SSE обычно не страдает. Но если кто-то добавил text/event-stream (или *), gzip копит байты до целого блока. Убрать один тип из списка нельзя, поэтому выключите gzip в location с потоком:

gzip off;   # внутри location, который отдаёт поток

Слой 3: само приложение. Многие фреймворки держат ответ до возврата из обработчика. Заголовки надо отправить первыми, а каждое событие писать по мере появления.

// Node.js
res.writeHead(200, {
  'Content-Type': 'text/event-stream',
  'Cache-Control': 'no-cache',
  'Connection': 'keep-alive',
  'X-Accel-Buffering': 'no',
});
res.flushHeaders();

function send(data) {
  res.write(`data: ${JSON.stringify(data)}\n\n`);
}

Событие заканчивает пустая строка, поэтому нужны оба \n: с одним браузер продолжает ждать.

Node не буферизует res.write, а вот middleware compression в Express буферизует. Отключите его для этого маршрута или вызывайте res.flush() после каждого события.

# Flask
return Response(
    generate(),
    mimetype="text/event-stream",
    headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)

generate() должен отдавать через yield целые события ("data: …\n\n") по одному и по мере появления. Если сначала собрать список, буферизацию вы написали сами.

Вторая ловушка в Python живёт в синхронном воркере gunicorn: он обслуживает одно соединение за раз и убивает запрос по --timeout, а это 30 секунд по умолчанию. Для WSGI берите воркеры gevent или eventlet, для ASGI подойдёт uvicorn.

Как проверить, какой слой виноват

Два запроса:

# прямо в приложение, мимо nginx
curl -N http://127.0.0.1:8000/events

# через nginx
curl -N https://example.com/events
  • Первый ровный, второй пачкой: копит nginx.
  • Оба пачкой: копит приложение.
  • Оба ровные, а браузер всё равно ждёт: проверьте Content-Type. EventSource требует ровно text/event-stream; на любом другом типе он выдаёт error и закрывает соединение.

Как увидеть это у себя

В ответе text/event-stream время прихода важно не меньше содержимого. Прокси показывает тело по мере поступления. События идут по одному с паузами, значит поток живой; всё тело приходит сразу через сорок секунд, значит его где-то копили. Тот же ответ, что дают два curl выше, но без доступа к серверу. Как Solpuga записывает стрим: Перехват и просмотр трафика.

Посмотреть в Solpuga

Инструмент к этой странице

Разбор SSE-потокаВставьте сырой поток Server-Sent Events и увидите события, id, retry и ошибки формата.

Рядом