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

One stays HTTP the whole way; the other stops being HTTP at the 101 A server-sent event stream is a single HTTP response with a content type of text/event-stream that never ends, so every HTTP-aware tool in the path continues to understand it. A WebSocket becomes an opaque framed connection after the upgrade, which is what makes it more capable and what makes intermediaries more likely to interfere with it. server-sent events GET /events → 200 OK · content-type: text/event-stream · connection stays open still HTTP: logs, caches, WAFs, CDNs and curl all still understand what they are looking at websocket GET /ws → 101 HTTP up to here framed, bidirectional, opaque to every HTTP-aware component more capable, and more likely to be mishandled in the path If the data only flows one way, the top row buys you the entire HTTP toolchain for free.

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.

Reconnection that resumes instead of restarting The client receives events up to id 4413 and the connection drops. The browser reconnects automatically after the retry interval, sending Last-Event-ID 4413, and the server continues from 4414. No client code is involved and no events are lost, provided the server keeps a short replay buffer. events to id 4413 delivered connection drops no client code runs browser reconnects last-event-id: 4413 resumes at 4414 The server needs a replay buffer covering at least the retry interval, and must tolerate an id it has never seen — a client reconnecting after an hour will send one, and the correct answer is to start fresh rather than to fail. Set retry high enough that a rolling deploy does not turn into a synchronised reconnect: five seconds plus jitter applied server-side beats every client reconnecting on the same schedule.

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.

Four places bytes get held, and what turns each one off The application must flush after each event. The gateway must not buffer the proxied response. A CDN must not cache or buffer the stream. Compression must either be disabled for the event-stream content type or configured to flush. Any one of the four left at its default produces events arriving in bursts. application flush after each event gateway proxy_buffering off CDN cache-control: no-store browser EventSource Compression sits across the middle two and buffers by default — exclude text/event-stream or configure flushing. Bisect with curl -N at each hop. The first hop where events stop trickling and start arriving in groups is the one holding bytes, and you can usually prove it in two commands rather than by reading four config files. A stream that works in staging and bursts in production almost always means a CDN nobody included in the test.

Validation

  • curl -N https://app.example.com/events shows events arriving one at a time, not in bursts
  • Response carries content-type: text/event-stream and cache-control: no-store
  • Killing the upstream mid-stream produces a reconnect with Last-Event-ID set
  • Server tolerates an unrecognised Last-Event-ID by 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