Server-Sent Events (SSE)
One-way streaming from server to client over plain HTTP. Browser’s EventSource reconnects automatically. For LLM token streaming, progress updates, notifications — usually a better fit than WebSockets if you don’t need duplex.
The protocol
A long-lived HTTP response with Content-Type: text/event-stream. The server sends a stream of text events:
data: First message\n\n
event: ping\n
data: keepalive\n\n
event: order_placed\n
id: 42\n
data: {"id":"ord_42","total":99.50}\n\n
retry: 5000\n\n
Format rules:
- Each event is
field: value\nlines, terminated by a blank line (\n\n). data:— the event payload. Multipledata:lines are joined with newlines.event:— optional event name (default:message).id:— optional event ID. Browser remembers and sends on reconnect.retry:— milliseconds to wait before reconnecting after disconnect.- Lines starting with
:are comments (used as keepalive pings).
FastAPI server example
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import asyncio, json
app = FastAPI()
async def event_stream():
counter = 0
while True:
counter += 1
payload = {"count": counter, "ts": time.time()}
yield f"id: {counter}\nevent: update\ndata: {json.dumps(payload)}\n\n"
await asyncio.sleep(1)
@app.get("/events")
async def events():
return StreamingResponse(event_stream(), media_type="text/event-stream")
Browser side:
const es = new EventSource("/events");
es.addEventListener("update", (msg) => {
const data = JSON.parse(msg.data);
console.log(data);
});
es.onerror = (err) => console.error(err);
Automatic reconnection on disconnect, automatic event ID tracking, automatic parsing of the line-based format.
SSE vs WebSocket
| SSE | WebSocket | |
|---|---|---|
| Direction | server → client only | bidirectional |
| Protocol | plain HTTP/HTTPS | separate ws:// / wss:// |
| Reconnection | automatic (EventSource) | you implement |
| Last-Event-ID | sent on reconnect | you implement |
| Browser support | universal | universal |
| Through proxies / CDNs | usually fine | sometimes blocked |
| Per-connection cost | one HTTP connection (TCP) | one TCP+upgrade connection |
| Binary | no (text only) | yes |
| Authentication | standard HTTP (cookies, headers) | quirky (handshake before upgrade) |
| Backpressure | trivial (HTTP) | trickier |
Default to SSE when traffic is server → client only. LLM streaming, progress updates, notifications, log tails, live dashboards. Reach for WebSocket when the client also needs to send data continuously (chat, collaborative editing, games).
Last-Event-ID — auto-resume on reconnect
const es = new EventSource("/events"); // includes Last-Event-ID header after first connection
When the browser reconnects (after network blip), it includes Last-Event-ID: 42 in the request. Your server should resume from event 43:
@app.get("/events")
async def events(last_event_id: int | None = Header(None, alias="Last-Event-ID")):
start = (last_event_id or 0) + 1
async def generate():
for i in range(start, ...):
...
return StreamingResponse(generate(), media_type="text/event-stream")
For this to work, your server needs to keep enough event history that catch-up is possible (Redis stream, DB log, in-memory ring buffer with bounded retention).
The nginx proxy_buffering gotcha
nginx buffers responses by default — fine for normal API responses, breaks SSE because the events arrive at the client in big chunks instead of streaming.
location /events {
proxy_pass http://backend;
proxy_buffering off; # CRITICAL
proxy_cache off;
proxy_read_timeout 86400; # 24h — let connections live
proxy_set_header Connection ""; # disable HTTP/1.0 connection close
}
Without proxy_buffering off, your events sit in the nginx buffer until it fills (or the connection ends). Symptom: client sees no events for minutes, then a burst.
Also: most cloud LBs (AWS ALB / CloudFront / Cloudflare) have idle timeouts. ALB default is 60 seconds. Send a keepalive comment line periodically:
async def event_stream():
while True:
if has_data():
yield format_event(...)
else:
yield ":keepalive\n\n" # comment line; client ignores; resets idle timer
await asyncio.sleep(15)
Client disconnect handling
When the browser closes the page or the network drops, the server’s generator should stop. FastAPI / Starlette signal this via request.is_disconnected():
@app.get("/events")
async def events(request: Request):
async def generate():
try:
while not await request.is_disconnected():
if data := await get_next_event():
yield format_event(data)
else:
yield ":keepalive\n\n"
await asyncio.sleep(0.1)
finally:
# Cleanup — release subscriptions, close DB cursors, etc.
cleanup()
return StreamingResponse(generate(), media_type="text/event-stream")
await request.is_disconnected() polls the underlying socket. Without checking it, your generator runs forever for a disconnected client.
Backpressure
Slow consumers hold the connection open with bytes queued. Without backpressure handling:
- The server’s send buffer fills.
- The generator suspends on the next
yield. - The server hangs onto whatever resources the generator references (DB cursor, Redis subscription).
For LLM token streaming where tokens arrive at a known rate, this rarely matters. For high-volume event streams, consider:
- A max-buffer policy: drop oldest events if buffer exceeds N.
- Coalescing: merge multiple events into one if the client is behind.
- Connection close: drop slow clients ruthlessly.
Authentication
SSE rides standard HTTP, so:
- Cookies work —
EventSourceincludes them automatically. - Bearer tokens via header: tricky in browsers (
EventSourcedoesn’t allow custom headers). Workarounds:- Token in query string (logged, leaks via referrer).
- Cookie-based auth (best for browsers).
- Polyfilled EventSource libraries that allow headers.
For non-browser clients (Python httpx, mobile apps), header auth is straightforward.
SSE for LLM streaming — the canonical pattern
@app.post("/chat/stream")
async def chat_stream(request: ChatRequest):
async def generate():
async for chunk in llm.stream(request.messages):
payload = {"delta": chunk.text}
yield f"data: {json.dumps(payload)}\n\n"
yield "event: done\ndata: {}\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
OpenAI, Anthropic, and most LLM providers stream via SSE-style protocols natively. Wrap their stream → re-emit as SSE → client receives tokens as they’re generated.
The user experience benefit is huge: ~1s perceived latency vs ~10s for non-streaming.
Long-polling fallback
Old browsers / restrictive networks may break SSE. Fallback to long-polling:
@app.get("/long-poll")
async def long_poll(since: int = 0):
timeout = 30
start = time.time()
while time.time() - start < timeout:
if events := await get_events_since(since):
return {"events": events, "next": events[-1].id}
await asyncio.sleep(1)
return {"events": [], "next": since}
Client polls, server holds the request open up to N seconds, returns when data arrives or timeout. Less elegant but works everywhere.
Most modern apps don’t bother with long-polling fallback — SSE has 99%+ browser support since IE was dropped.
When SSE doesn’t fit
- Bidirectional communication (chat, collaboration, games) → WebSocket.
- Binary data (audio, video, files) → WebSocket, MediaSource, etc.
- Lots of duplex traffic → WebSocket.
- Push notifications to mobile apps → APNs/FCM; SSE doesn’t survive backgrounded apps.
Common gotchas
proxy_buffering on(nginx default) — events don’t reach the client in real time.- No keepalive comments — cloud LBs idle-timeout the connection mid-stream.
Connection: closein response — kills HTTP/1.1 keepalive.- EventSource auth headers — not supported in browsers; use cookies or query params (with care).
- Forgetting to clean up on disconnect — DB cursors / Redis subscriptions leak.
- Buffering at the application level — generator yields but the response isn’t flushed because of intermediate buffering.
- Reading the request body before starting the stream — Starlette / FastAPI wait for the full body before invoking the handler; if your handler doesn’t need a body, ensure it isn’t accidentally being awaited.
Python clients
import httpx
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", "https://example.com/events") as resp:
async for line in resp.aiter_lines():
line = line.strip()
if not line:
continue
if line.startswith("data:"):
payload = line[5:].strip()
handle(payload)
No third-party SSE library needed; httpx streaming + manual line parsing is enough. The sseclient-py library wraps this with the protocol semantics (event names, IDs, reconnect logic).
Interview angle
- “SSE vs WebSocket — when each?” — SSE for server-to-client streaming over plain HTTP, automatic reconnect, simpler operations. WebSocket for bidirectional traffic (chat, games, collaborative editing). Default to SSE if traffic is one-way.
- “How does SSE handle reconnection?” — browser’s EventSource auto-reconnects on disconnect, sends
Last-Event-IDheader. Server resumes from the next event. Your server must retain event history (Redis stream / DB log / ring buffer) for catch-up to work. - “Why use SSE for LLM streaming?” — token-by-token delivery, simple protocol, browser-native EventSource, plays well through proxies and CDNs. OpenAI / Anthropic stream natively via SSE-style protocols.
- “What’s the nginx proxy_buffering gotcha?” — nginx buffers responses by default; SSE events sit in the buffer until it fills. Set
proxy_buffering off;on the SSE location. Without it, your client sees nothing for minutes, then a burst. - “How do you handle client disconnects?” —
await request.is_disconnected()in a Starlette/FastAPI generator. Check periodically; clean up resources when the client is gone. Without checking, generators run forever. - “Why do you need keepalive comments?” — cloud load balancers (AWS ALB / CloudFront) have idle timeouts (~60s). A connection with no traffic for 60s gets dropped. Sending
:keepalive\n\nperiodically (every 15-30s) keeps the idle timer reset. - “Auth with SSE in browsers?” — cookies (standard, EventSource sends them). Custom headers (Authorization: Bearer) aren’t directly supported by browser EventSource — use cookie-based auth or polyfilled clients that allow headers.