"SSE is the protocol every AI chat app uses to stream tokens. It's text over HTTP/1.1. EventSource handles it in 5 lines of browser JS. It does one thing — server pushes events to client — and does it well."
What SSE Is
Server-Sent Events is a W3C specification for one-way HTTP streaming: server pushes a sequence of named events to a client over a long-lived HTTP/1.1 (or 2 or 3) response. The connection is initiated by the client (a normal GET) and stays open; the server writes events as they happen.
Why it exists: there's a class of applications (chat tokens, real-time dashboards, log tailing, notifications) where the server needs to push but the client doesn't need to push back. WebSocket can do this but adds protocol-upgrade complexity. SSE is just a long-running HTTP response with a special content type and body format.
The Wire Format — Simpler Than You'd Guess
Content-Type: text/event-stream. Body is UTF-8 text with three field types separated by single newlines, events separated by blank lines:
event: message
data: {"role":"assistant","content":"Hi "}
id: 42
event: message
data: {"role":"assistant","content":"아빠."}
id: 43
event: done
data: {"final_id":"m_abc"}
Fields:
event:— the event name. Optional; defaults to "message".data:— the payload (typically JSON). Multi-line data is multipledata:lines, concatenated with \n.id:— event ID for reconnection. Browser remembers the last seen ID and sends it as Last-Event-ID header on reconnect.retry:— number of ms before client retries on connection loss.- blank line — separates one event from the next.
The Browser Side — EventSource Is Five Lines
Modern browsers ship a native EventSource API that handles everything:
const es = new EventSource('/api/chat/stream');
es.addEventListener('message', (e) => render(JSON.parse(e.data)));
es.addEventListener('done', (e) => es.close());
es.onerror = () => { /* auto-reconnects unless es.close() */ };
The browser handles reconnection, Last-Event-ID, parsing, and event dispatch. You write five lines and have a streaming UI.
The Server Side — Async Streaming Response
Server-side SSE is just a long-running HTTP response that writes events as they occur. In FastAPI:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json, asyncio
app = FastAPI()
@app.get('/api/chat/stream')
async def chat_stream():
async def event_stream():
for i, token in enumerate(['Hi', ' ', '아빠', '.']):
yield f'event: message\ndata: {json.dumps({"content": token})}\nid: {i}\n\n'
await asyncio.sleep(0.1)
yield 'event: done\ndata: {}\n\n'
return StreamingResponse(event_stream(), media_type='text/event-stream')
The body is an async generator yielding text. Starlette/FastAPI flushes each yield to the wire immediately. The client sees each event as it arrives.
The Gotchas
1. Disable response buffering. Nginx, Apache, and other reverse proxies buffer responses by default — which means events accumulate at the proxy until the response is fully written. Add X-Accel-Buffering: no (Nginx) or equivalent to tell the proxy to pass through immediately.
2. Mind the chunked encoding. SSE responses use Transfer-Encoding: chunked because the body length isn't known in advance. Don't set Content-Length.
3. Heartbeats prevent timeouts. Intermediaries may close idle long-lived connections. Send a comment line (: heartbeat\n\n) every 15-30s to keep the connection warm. Browsers ignore comments.
4. CORS still applies. Cross-origin SSE requires the server to send Access-Control-Allow-Origin. The EventSource API respects CORS like any other fetch.
cwkPippa's SSE Reality
backend/adapters/claude.py's stream method, which yields events through Starlette's StreamingResponse. The frontend's useChat hook (frontend/src/hooks/useChat.ts) consumes them via a custom SSE parser (not EventSource — we want POST semantics, which EventSource doesn't support — POST + manual EventStream parsing). The JSONL ground-truth log captures every event before it goes to the wire, so a disconnected client can re-render the conversation from the log when it reconnects.