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 ismessageif omitted. Clients dispatch by this name.id:— event ID. The browser remembers the last seen ID and sendsLast-Event-IDon 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 easierSSE vs WebSocket — Where They Diverge
| Axis | SSE | WebSocket |
|---|---|---|
| Direction | Server → client, one-way | Bidirectional |
| Transport | Plain HTTP/1.1 or HTTP/2 | Dedicated ws:// protocol (HTTP Upgrade) |
| Data | UTF-8 text only | Text + binary |
| Reconnect | Browser auto + Last-Event-ID resume | You implement it |
| Auth · CORS | Plain HTTP — cookies / Authorization / CORS as usual | Different model (cookies on handshake only) |
| Proxies · firewalls | HTTP, so usually passes through | Some corporate proxies / gateways block it |
| Good for | Notifications, progress, AI token streams, live feeds | Chat, 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 })plusAccess-Control-Allow-Credentials: trueand a specific origin (same CORS rules — seecors-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:orid:but nodata: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?".