SSE — How the Server Speaks First
Why SSE Fits More Often Than WebSocket
In one line
SSE is an HTTP response that never ends. It is not a new protocol, so proxies, authentication, reconnection, and logging all keep working as they are.
What's different
| SSE | WebSocket | |
|---|---|---|
| Protocol | Plain HTTP | Separate after Upgrade |
| Direction | Server → client | Bidirectional |
| Reconnection | The browser does it automatically | Build it yourself |
| Resuming what was missed | Last-Event-ID is built in |
Build it yourself |
| Authentication | Cookies and headers work as they are | Must be layered onto the handshake |
| Proxy/LB | Existing configuration works | Separate configuration needed |
| Data | Text (UTF-8) | Text + binary |
If the client doesn't need to keep talking to the server, SSE is almost always the right choice. Notifications, progress, dashboard updates, and LLM token streaming all fit. User input can be sent as a plain POST.
Wire format
The header is Content-Type: text/event-stream, and the body looks like this.
event: token
id: 42
data: 안녕
data: 여러 줄이면
data: data: 를 반복한다
: 이건 주석이다. 하트비트로 쓴다
retry: 3000
The rules fit in five lines.
- A blank line ends an event. If you don't send the blank line, the client thinks the event isn't finished and keeps waiting — the number one cause of "why is nothing arriving"
- If there are several
data:lines, they are joined with newlines into a single string event:is the event name. If omitted, it ismessage- If you give an
id:, the browser remembers it and sends it back in theLast-Event-IDheader on reconnection retry:is the reconnection wait time (in milliseconds)- A line starting with
:is a comment. It means nothing, but bytes flow, so the connection stays alive
The browser side is three lines
const es = new EventSource("/stream")
es.addEventListener("token", e => append(e.data))
es.onerror = () => { /* 브라우저가 알아서 다시 붙는다 */ }
If the connection drops, it reconnects automatically and sends the last id it received in the Last-Event-ID header. If the server sends from the event after that id, the user never notices the drop. To do the same with WebSocket, you have to build reconnection, deduplication, and ordering yourself.
So why doesn't it work — buffering
Most of the problems you actually hit with SSE come down to one.
The code is correct, but everything appears on screen at once.
Someone in the middle is collecting the response. There are three culprits.
1. The reverse proxy. nginx buffers responses by default.
proxy_buffering off; # location 에
Or turn it off from the app with a header — X-Accel-Buffering: no.
2. Compression. gzip has to collect data in blocks to compress. With Content-Encoding: gzip attached, streaming is effectively dead. Exclude SSE responses from compression.
3. The framework. This is the case where code that builds the whole response and returns it at once is mistaken for streaming. If you build a list and return it instead of using yield in a generator, it is just one big response.
Diagnose by time. Connect with curl -N and look at when the first byte arrives. If it arrives only after everything has been built, something is collecting it. -N is the option that turns off curl's own buffering.
Without a heartbeat, the connection drops
Load balancers and proxies have an idle timeout (usually 60 seconds). If no bytes flow, they cut the connection. So you periodically let a comment line through.
: ping
The client doesn't treat this as an event. Only bytes flow. An interval of 15–30 seconds is enough.
Connection limit
Under HTTP/1.1, browsers limit concurrent connections to 6 per origin. One SSE stream permanently holds one of them. Open six tabs and every request from the seventh tab stalls. The problem disappears with HTTP/2 (multiplexing). If you use SSE in production, HTTP/2 is not optional.
What gets forgotten on the server side
The generator keeps running even after the client disconnects — unless it notices and stops. Every time a user closes a tab, one more zombie task piles up on the server.
async def gen():
try:
while True:
yield {...}
finally:
await cleanup() # 끊길 때 반드시 여기로 온다
Adding finally should become a habit.
The order in which to diagnose in the field
When a report comes in that "the code is correct but everything appears on screen at once," before touching the server code, first work out how far it flows.
curl -N http://앱주소/stream(the placeholder is the app address) — connect directly to the app.-Nis the option that turns off curl's own output buffering. If you leave it out, the tool makes you suspect the server- Make the same request once more through the proxy. If it clumps up only here, the culprit is buffering or compression
- The EventStream view in the browser developer tools Network tab — check whether events arrive one at a time
Narrowing it down one step at a time settles within minutes whether the server, the proxy, or the client needs fixing. When diagnosing, the first thing to suspect is the measuring tool itself.
When to use WebSocket
- The client sends often (chat input, cursor position, games)
- You need binary (audio, screen sharing)
- Round-trip latency matters at the level of a few milliseconds
Otherwise, SSE is much cheaper to operate. Choosing by what gets recovered automatically when the connection drops usually gives the answer.