SSE arrives in one chunk instead of a stream

Server-Sent Events land all at once instead of streaming. Three layers hold them: proxy_buffering, gzip and the app itself. How to test each.

What it means

The server writes events one at a time. The browser gets them in a batch: thirty seconds of nothing, then twenty message events at once. curl -N on the same URL streams fine, so the bytes do leave the app and something between the app and the browser is holding them. That something is almost always nginx.

How to fix it

Three layers hold a stream. Check them in this order.

Layer 1: nginx. proxy_buffering is on by default. nginx collects the whole response, frees the backend, then feeds the client at its own pace. For a stream that is exactly wrong:

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;
}

Line by line:

  • proxy_buffering off is the actual fix. Every chunk from the backend goes straight to the client.
  • proxy_cache off is already the default, but a cache inherited from a parent block would collect the stream. Say it here anyway.
  • chunked_transfer_encoding on restates the default, and it has to stay on. Copied configs often set it off; that breaks SSE. A stream has no Content-Length, so chunked encoding is what frames it.
  • proxy_read_timeout defaults to 60s. A stream that goes quiet dies at the minute mark. That is a separate problem.
  • proxy_http_version 1.1 plus proxy_set_header Connection '' fixes something else. nginx sends Connection: close to the backend by default, and old versions proxy over HTTP/1.0. These two let an upstream … keepalive pool reuse the connection, which is the persistence RFC 9112 § 9.3 describes. They do not affect buffering.

Cannot touch the config? Send a header from the application instead:

X-Accel-Buffering: no

nginx reads it and turns buffering off for that one response. This is the more portable option, because the header ships with the code. It only works if nginx is the proxy and proxy_ignore_headers is not dropping it.

Layer 2: compression (Content-Encoding). nginx gzips only the types listed in gzip_types, and the default list is text/html alone, so SSE is usually safe. If somebody added text/event-stream (or *), gzip holds bytes until it has a block to emit. There is no way to subtract one type from the list, so turn gzip off in the SSE location:

gzip off;   # inside the location that serves the stream

Layer 3: the application. Many frameworks hold the response until the handler returns. Send the headers first, then write each event as it happens.

// 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`);
}

A blank line is what ends an event, so both \n are required: with one the browser keeps waiting.

Node does not buffer res.write, but Express's compression middleware does: skip it for this route, or call res.flush() after every event.

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

generate() has to yield complete events ("data: …\n\n") one by one, as they happen. Build a list first and you have written the buffering yourself.

Gunicorn's sync worker is the other Python trap: it handles one connection at a time and kills the request at --timeout, 30 seconds by default. Use gevent or eventlet workers for WSGI, or run an ASGI app under uvicorn.

Finding out which layer it is

Two requests:

# straight to the application, bypassing nginx
curl -N http://127.0.0.1:8000/events

# through nginx
curl -N https://example.com/events
  • First steady, second batched: nginx is buffering.
  • Both batched: the application is buffering.
  • Both steady, browser still waits: check Content-Type. EventSource needs exactly text/event-stream; on anything else it fires error and closes.

See it on your own traffic

In a text/event-stream response the timing matters as much as the body. A proxy shows the body filling in: events one at a time with gaps means the stream is alive, the whole body landing at once after forty seconds means something collected it. Same answer as the two curl runs, without a shell on the server. See Capture & Inspect Traffic for how Solpuga records a stream.

View in Solpuga

Tool for this page

SSE stream parserPaste a raw Server-Sent Events stream and see the events, ids, retry hints and framing mistakes.

Related