Server-Sent Events vs WebSockets at the Edge
Server-sent events are the transport most teams skip past on the way to WebSockets, and then adopt eighteen months later after a series of upgrade-related incidents. For one-directional feeds — notifications, progress, live metrics, streamed model output — they are ordinary HTTP, they reconnect themselves, and they pass through intermediaries that mangle upgrades. Their single failure mode is buffering, and it is entirely configurable. This page compares the two honestly and gives the configuration that keeps an event stream flowing.
Prerequisite concepts
This assumes the connection-cost model from streaming, WebSocket and realtime gateways and the upgrade mechanics covered in proxying WebSockets through Kong and Envoy.
The difference that decides it
The practical consequence is that an SSE stream can be curled, logged, inspected by a WAF and served through a CDN, while a WebSocket becomes a byte tunnel that only the two endpoints understand.
The one thing that must be configured
Buffering. Every proxy in the path defaults to accumulating response bytes before forwarding them, which is correct for ordinary responses and fatal for a stream: events arrive in a burst when the buffer fills or the response ends, which for a stream is never.
# NGINX / Kong 3.x — the four directives that matter
location /events {
proxy_pass http://realtime.internal:8080;
proxy_http_version 1.1;
proxy_set_header Connection ""; # no keepalive header confusion
proxy_buffering off; # the critical one
proxy_cache off;
proxy_read_timeout 3600s;
chunked_transfer_encoding on;
}
# Envoy 1.32+ — do not buffer, and let the stream run
route:
cluster: realtime_upstream
timeout: 0s
idle_timeout: 600s # longer than the heartbeat interval
# and ensure no buffer filter is applied to this route:
typed_per_filter_config:
envoy.filters.http.buffer:
"@type": type.googleapis.com/envoy.extensions.filters.http.buffer.v3.BufferPerRoute
disabled: true
The upstream has its own share of this. A response must be flushed after each event, the Content-Type must be text/event-stream, and X-Accel-Buffering: no is worth sending as a belt-and-braces signal that NGINX-family proxies honour.
The wire format, and the part that does the work
retry: 5000
event: progress
id: 4412
data: {"stage":"rendering","pct":62}
: heartbeat comment — keeps every idle timer fed
event: progress
id: 4413
data: {"stage":"rendering","pct":71}
Two lines carry most of the operational value. id: is echoed back by the browser as a Last-Event-ID request header on reconnection, which lets the server resume rather than restart — the single feature that makes SSE resilient without client-side code. retry: sets the client’s reconnection delay in milliseconds, which means the server controls the reconnect storm rather than the client library.
Decision matrix
| Requirement | Choose |
|---|---|
| Server pushes, client only reads | server-sent events |
| Client must push on the same connection | WebSocket |
| Must traverse unknown corporate proxies | server-sent events |
| Browser client, want automatic reconnection | server-sent events |
| Binary payloads | WebSocket |
| Thousands of idle connections per node | either; the cost is the same |
| Needs to work through a caching CDN | server-sent events, with caching explicitly disabled |
| Sub-millisecond bidirectional latency | WebSocket |
The connection cost is identical — both hold a socket — so the choice is about direction, payload and how hostile the network path is, not about efficiency.
Gotchas and failure signals
HTTP/1.1 browsers cap connections per origin at six, and each SSE stream consumes one. Three open tabs on a page with two streams exhausts the budget and further requests to that origin hang with no error. HTTP/2 multiplexes and removes the problem entirely, which is the strongest argument for serving streams over HTTP/2.
A CDN in front of the stream will cache it unless told not to. The symptom is spectacular: every client receives the same replayed event sequence from cache. Set Cache-Control: no-store on the response and confirm at the edge.
Events arriving in bursts rather than a trickle is buffering, every time. Bisect by curling the stream at each hop — the first hop that bursts is the one holding bytes.
Compression buffers by default. gzip on a stream accumulates bytes before emitting a block, so either disable compression for text/event-stream or ensure flushing is configured on the encoder.
Validation
-
curl -N https://app.example.com/eventsshows events arriving one at a time, not in bursts - Response carries
content-type: text/event-streamandcache-control: no-store - Killing the upstream mid-stream produces a reconnect with
Last-Event-IDset - Server tolerates an unrecognised
Last-Event-IDby starting fresh - Heartbeat comment interval is below the smallest idle timer in the path
- Streams are served over HTTP/2 wherever browser clients are involved
FAQ
When should I prefer server-sent events?
Whenever the data only flows from server to client. SSE remains ordinary HTTP the whole way, so logs, caches, WAFs and curl all still understand it, and browsers reconnect automatically with a Last-Event-ID header that lets the server resume rather than restart. Choose WebSockets when the client genuinely needs to push on the same connection or when payloads are binary.
Why do my events arrive in bursts instead of a trickle?
Something in the path is buffering the response. Proxies accumulate response bytes by default, which is correct for ordinary responses and fatal for a stream that never ends. Disable proxy buffering and caching on the route, send X-Accel-Buffering: no from the upstream, and bisect by curling each hop — the first one that bursts is holding the bytes.
What does Last-Event-ID actually do?
The browser records the id of the last event it received and sends it as a request header when it reconnects. A server that keeps a short replay buffer can resume from the next event instead of restarting the stream, which makes a dropped connection invisible to the user with no client-side code at all. The server must also tolerate an id it no longer holds by starting fresh.
Does the six-connection browser limit affect SSE?
Over HTTP/1.1, yes, and badly: each open stream consumes one of the six connections a browser allows per origin, so a few tabs can exhaust the budget and further requests hang with no error. HTTP/2 multiplexes streams over one connection and removes the problem, which is the strongest reason to serve event streams over HTTP/2.
Parent: Streaming, WebSocket & Realtime Gateways
Related
- Streaming, WebSocket & Realtime Gateways — connection accounting that applies equally to both transports.
- Proxying WebSockets Through Kong and Envoy — the alternative when the client also needs to push.
- Caching & Response Optimization — why a CDN in front of a stream needs caching explicitly disabled.