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 offis the actual fix. Every chunk from the backend goes straight to the client.proxy_cache offis already the default, but a cache inherited from a parent block would collect the stream. Say it here anyway.chunked_transfer_encoding onrestates the default, and it has to stay on. Copied configs often set itoff; that breaks SSE. A stream has noContent-Length, so chunked encoding is what frames it.proxy_read_timeoutdefaults to60s. A stream that goes quiet dies at the minute mark. That is a separate problem.proxy_http_version 1.1plusproxy_set_header Connection ''fixes something else. nginx sendsConnection: closeto the backend by default, and old versions proxy over HTTP/1.0. These two let anupstream … keepalivepool 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.EventSourceneeds exactlytext/event-stream; on anything else it fireserrorand 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.
Tool for this page
SSE stream parserPaste a raw Server-Sent Events stream and see the events, ids, retry hints and framing mistakes.Related
- The stream dies after 60 seconds: proxy_read_timeoutA stream that dies on the same second is a timeout, not the network. Default values in nginx, ALB, Cloudflare, Heroku and Envoy, and why a heartbeat is safer.
- Replacement characters «�» in a stream: TextDecoderA multi-byte character was cut at a chunk boundary. One decoder and one flag fix it, and the same mistake is hiding one level up, in the SSE parsing.