C.W.K.
Stream
Lesson 02 of 05 · published

Server-Sent Events — One-Way Streaming, Already in Your Browser

~11 min · streaming-async, sse, event-stream, ai-streaming

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"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 multiple data: 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.

SSE is HTTP doing what it already does, just slowly. No new protocol, no new connection upgrade, no special server requirements beyond async-capable runtime. The 'streaming' is just an HTTP response that takes minutes to finish instead of milliseconds. Every reverse proxy, every CDN, every HTTP library understands the underlying transport.

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

cwkPippa's POST /api/chat is the canonical SSE producer in this codebase — it streams Claude/Codex/Gemini/Ollama tokens as they arrive from each provider. The server-side pattern lives in 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.

Code

SSE wire format — headers, comment heartbeats, named events·http
# A raw SSE response on the wire
GET /api/chat/stream HTTP/1.1
Accept: text/event-stream

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked
X-Accel-Buffering: no

: heartbeat

event: message
data: {"content":"Hi "}
id: 1

event: message
data: {"content":"아빠."}
id: 2

event: done
data: {"final_id":"m_abc"}

# (server closes connection when done; or client closes when it's seen enough)
FastAPI SSE producer — async generator + StreamingResponse·python
# FastAPI server — streaming SSE with backpressure
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import json, asyncio

app = FastAPI()

async def stream_tokens():
    """Yield SSE-formatted events as tokens arrive from an upstream LLM."""
    for i, token in enumerate(['Hi', ' ', '아빠', '.']):
        # SSE event: 'event:' line + 'data:' line + 'id:' line + blank separator
        yield f'event: message\ndata: {json.dumps({"content": token})}\nid: {i}\n\n'
        await asyncio.sleep(0.1)
    # Terminal event so the client knows to close cleanly
    yield 'event: done\ndata: {}\n\n'

@app.get('/api/chat/stream')
async def chat_stream():
    return StreamingResponse(
        stream_tokens(),
        media_type='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'X-Accel-Buffering': 'no',  # disable Nginx buffering
        },
    )
Browser: EventSource (5 lines) or fetch + manual SSE parser for POST·javascript
// Browser client — five lines with native EventSource
const es = new EventSource('/api/chat/stream');
es.addEventListener('message', (e) => {
  const { content } = JSON.parse(e.data);
  appendToUi(content);
});
es.addEventListener('done', () => es.close());
es.onerror = () => console.warn('SSE disconnected; browser will auto-reconnect');

// If you need POST semantics (sending a body with the request — required for chat),
// EventSource doesn't support POST. Use fetch with a streaming reader instead:
const resp = await fetch('/api/chat/stream', {
  method: 'POST',
  headers: {'Content-Type': 'application/json'},
  body: JSON.stringify({message: 'hi pippa'}),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buf += decoder.decode(value, { stream: true });
  for (const event of parseSSEFromBuffer(buf)) handle(event);
  buf = remainder(buf);
}

External links

Exercise

Build a FastAPI SSE endpoint at GET /clock that streams the current time once per second for 30 seconds, then emits an 'end' event. Consume it from a browser EventSource. Then deliberately add a 60-second silence in the middle and watch the connection drop (Nginx or your runtime times out). Add heartbeat comments every 15s and watch the connection survive. Bonus: deliberately serve the endpoint through a reverse proxy without disabling buffering and observe how the events accumulate then flush all at once.
Hint
EventSource auto-reconnects when the connection drops — so when your 60s silence drops the connection, the browser will start a new SSE session a few seconds later. The heartbeat fix is : \n\n (literally a colon-space-newline-newline) yielded every 15 seconds. The buffering bonus is the most educational — you'll SEE the events stream when buffering is off and arrive in a clump when it's on.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.