Skip to content
yutils

How Server-Sent Events Actually Work

The text/event-stream protocol, the EventSource API, automatic reconnection with Last-Event-ID, when SSE beats WebSocket, and the real-world limits (one-way only, browser connection caps, proxy buffering).

~9 min read

The server wants to tell the client "something happened". WebSocket is overkill, polling is wasteful. Server-Sent Events (SSE) streams text over plain HTTP for one-way push — the text/event-stream media type, the EventSource API, and automatic reconnection. This guide covers the protocol, the limits, and where SSE wins or loses against WebSocket.

The Problem SSE Solves

Server → browser, one-way push needed:
- Notifications, progress, live feeds, AI token streaming, dashboards

Options:
1. Polling — GET every second → latency + waste
2. Long-polling — held GET, reply on event → reconnect each event
3. WebSocket — bidirectional socket, heavy and often blocked by proxies
4. SSE — one-way stream over HTTP + auto reconnect + Last-Event-ID resume

Most "server → client" alerts are one-way, so SSE is enough.

The Protocol — text/event-stream

A regular HTTP response with Content-Type: text/event-stream, signaling "this is a stream". The response body never closes; the server keeps emitting text frames line by line.

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive

data: hello

data: {"user": "jade", "msg": "hi"}

event: ping
data: 1

id: 42
data: progress 50%

retry: 3000
data: reconnect after 3 seconds

(connection stays open, more frames follow...)

Messages are separated by a blank line (\n\n). Within a message, only four colon-prefixed fields matter:

  • data: — payload. If repeated on multiple lines, they join with newlines into a single string.
  • event: — event name. Default is message if omitted. Clients dispatch by this name.
  • id: — event ID. The browser remembers the last seen ID and sends Last-Event-ID on reconnect.
  • retry: — reconnect wait time in ms, overriding EventSource's default.

EventSource — The Client API

const es = new EventSource("/api/events");

es.onmessage = (e) => {
  // messages without an explicit event:
  console.log(e.data);
};

es.addEventListener("ping", (e) => {
  // only event: ping messages
  console.log("ping", e.data);
});

es.onerror = (err) => {
  // reconnecting — EventSource retries automatically unless you close it
};

// explicit close
es.close();

Key point: the browser handles reconnection. If the connection drops, EventSource waits the retry interval (about 3 s by default) and reissues the GET, attaching the last seen id: as a Last-Event-ID header. The server only needs to emit events after that ID — natural resume, baked into the protocol.

Last-Event-ID — The Heart of Resume

Server-side flow:
GET /api/events
  ← (no Last-Event-ID) → stream from ID 1

(connection drops, client last saw id = 42)

GET /api/events
Last-Event-ID: 42
  ← stream from ID 43 onward (43, 44, ...)

→ Without writing a line of recovery code, the client gets every event
   missed during the outage.

Caveats:
- The server must store events by ID somewhere (Redis Streams,
  Kafka, ring buffer, etc.)
- Infinite retention is unrealistic — TTL or "last N minutes" policy needed
- IDs need not be monotonic, but unique + comparable makes storage and
  lookup easier

SSE vs WebSocket — Where They Diverge

AxisSSEWebSocket
DirectionServer → client, one-wayBidirectional
TransportPlain HTTP/1.1 or HTTP/2Dedicated ws:// protocol (HTTP Upgrade)
DataUTF-8 text onlyText + binary
ReconnectBrowser auto + Last-Event-ID resumeYou implement it
Auth · CORSPlain HTTP — cookies / Authorization / CORS as usualDifferent model (cookies on handshake only)
Proxies · firewallsHTTP, so usually passes throughSome corporate proxies / gateways block it
Good forNotifications, progress, AI token streams, live feedsChat, games, collaborative editing

For a four-way comparison of real-time patterns (polling / long-polling / SSE / WebSocket), see the webhooks-vs-polling guide.

HTTP/1.1's 6-Connection Limit — The Biggest SSE Trap

Browsers cap concurrent connections per origin:
- HTTP/1.1: about 6 (Chrome / Firefox / Safari are similar)
- SSE permanently occupies one connection

Symptom: open six tabs on the same origin; the seventh tab's fetch hangs
       → users say "the site got slow after the SSE feature shipped"

Fixes:
1. Serve over HTTP/2 or HTTP/3 — multiplex over a single connection,
   limit lifted (Cloudflare / nginx 1.13.10+ / Caddy by default)
2. Separate subdomain — events.example.com gets its own origin
3. Shared worker — one tab receives, BroadcastChannel fans out

→ HTTP/2 is effectively a prerequisite for production SSE.

Proxy Buffering — The Second Common Trap

nginx, AWS ALB, Cloudflare, corporate proxies:
default behavior is to buffer responses for efficiency (smaller writes)

→ SSE's "line at a time, immediately" stream piles up for N seconds
   and then arrives in one chunk
→ From the client's view: "this isn't live"

Fix (nginx example):
  proxy_buffering off;
  proxy_cache off;
  proxy_set_header X-Accel-Buffering no;
  # or via response header:
  X-Accel-Buffering: no

Cloudflare:
  - Standard plans force response buffering
  - Enterprise has a "Disable Buffering" option, or use WebSocket / HTTP/2 push
  - Workaround: Cache-Control: no-transform + padding bytes in the body
    (in practice: a 2 KB padding chunk first to force a flush)

Debugging:
  curl -N https://api.example.com/events    # -N disables curl buffering
  → if curl is also delayed, server / proxy issue
  → if only the browser is delayed, it's browser buffer (common over plain HTTP)

Heartbeats / Keep-Alive — Detecting Dead Connections

SSE rides on TCP. Intermediate NATs / load balancers can silently kill
idle connections (often 60-300 s).

→ The server doesn't know "did it arrive?"
→ The client gets no onerror — just silence

Fix: periodic heartbeat line
  every 15-30 s emit:
    : keepalive\n\n      // colon-prefix = comment, ignored but sent
  or:
    event: ping\n
    data: 1\n\n

→ Confirms the TCP is alive + resets idle timeouts.

Where SSE Is the Wrong Fit

  • Bidirectional comms — chat, games, collaborative editing. SSE is one-way, so client → server needs a separate fetch (extra overhead).
  • Binary data — SSE is UTF-8 only. Streaming images needs base64 (33% overhead) — just pick WebSocket.
  • IE and some embedded browsers — no EventSource support. Largely irrelevant today, but verify for enterprise.
  • Very short bursts — if a notification fires once within a second, a single poll is simpler.

Real-World Use Cases

  • AI token streaming — OpenAI / Anthropic streaming APIs use SSE format. Clients consume tokens and update the UI as they arrive.
  • Build / CI progress — Vercel / Netlify stream live build logs over SSE.
  • Stocks / crypto tickers — classic one-way push, though thousands per second usually justify WebSocket binary frames.
  • Dashboard metrics — Grafana Live offers an SSE option.

Common Pitfalls

  • HTTPS is effectively required — over plain HTTP some browsers flush chunks later than expected (and HTTP/2 only runs over TLS).
  • EventSource withCredentials — to send cookies or Authorization, use new EventSource(url, { withCredentials: true }) plus Access-Control-Allow-Credentials: true and a specific origin (same CORS rules — see cors-explained).
  • No Authorization header support — EventSource API limit. Choices: token in the URL (risky, logged), cookies, or implement your own with fetch + ReadableStream (then you lose automatic reconnect).
  • Messages without data: are ignored — per spec, a frame with only event: or id: but no data: never dispatches.
  • JSON newlines — JSON.stringify produces a single line, but if you pretty-print, every line needs its own data: prefix or the payload breaks.
  • Server-side connection limits — node.js / Go handle thousands of concurrent connections fine, but classic thread-per-request servers (Apache prefork) run out fast.

Wrap-up

SSE is the right answer when WebSocket is overkill for one-way push. It rides on plain HTTP, and the protocol bakes in reconnection plus Last-Event-ID resume — the code stays short.

Pre-production checklist: HTTP/2 or a separate origin, proxy buffering off, heartbeats, event-ID retention. Miss any one and you'll burn hours debugging "why isn't this live?".

Back to guides