backend / protocols / sse / 01_server_sent_events.md

Server-Sent Events (SSE)

7 interview angles 7 min read source

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\n lines, terminated by a blank line (\n\n).
  • data: — the event payload. Multiple data: 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 — EventSource includes them automatically.
  • Bearer tokens via header: tricky in browsers (EventSource doesn’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: close in 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-ID header. 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\n periodically (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.