Streaming Responses with Server-Sent Events: A Practical Guide (with Python Code)
When I built my first "live" feature — a notification feed that had to update the moment new data arrived — I did what most people do: I polled. Every 10 seconds, the client hit the notifications endpoint and hoped something changed.
It worked. It also wasted bandwidth, hammered my database with identical queries, and added 5–10 seconds of latency to every update. When I switched to Server-Sent Events (SSE), the same feature became one HTTP connection, instant updates, and a client that reconnects on its own.
Here's how SSE works, when to use it (and when not to), and a working FastAPI + JavaScript implementation you can copy.
What SSE actually is
Server-Sent Events is a standard (part of HTML5) that lets a server push data to a client over a single plain HTTP connection. The server keeps the response open and writes events as they happen.
The wire format is deceptively simple. The Content-Type is text/event-stream, and each event looks like this:
event: progress
id: 3
data: {"step": 3, "percent": 60}
That's it. event: names the type, id: gives a sequence number used for reconnection, and data: is the payload. A blank line separates events. Because it's just HTTP, it works through firewalls and proxies without any protocol upgrade — unlike WebSockets, which need a ws:// handshake and a persistent bidirectional socket.
SSE vs WebSocket: which one do you need?
Short version: if the server needs to push to the client, SSE is usually enough. If the client also needs to push back (chat, multiplayer games, collaborative editing), you need WebSockets.
| Concern | SSE | WebSocket |
|---|---|---|
| Direction | Server → client only | Bidirectional |
| Protocol | Plain HTTP | ws:// upgrade handshake |
| Auto-reconnect | Built into EventSource | You build it |
| Event IDs | Built-in (id:) |
You build it |
| Firewalls/proxies | Works like a normal request | Sometimes blocked or buffered |
| Binary data | Text only | Yes |
| Code complexity | Low | Moderate |
For dashboards, live feeds, notifications, progress bars, and token streaming, SSE is the simpler tool. I've watched teams adopt WebSockets for a "live status bar" and end up writing a custom reconnection loop, a heartbeat timer, and a load balancer config they're afraid to touch. SSE gave me all of that for free.
A working server in FastAPI
FastAPI makes this clean. Here's an endpoint that streams job progress:
import asyncio
import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
async def event_stream(request: Request):
# Resume from the last event the client saw, if any
last_id = request.headers.get("last-event-id")
start = int(last_id) + 1 if last_id else 1
for i in range(start, 6):
# Respect client disconnect
if await request.is_disconnected():
break
payload = {"step": i, "percent": i * 20}
# SSE format: event name, id, data, blank line
yield f"event: progress\nid: {i}\ndata: {json.dumps(payload)}\n\n"
await asyncio.sleep(1)
yield "event: done\ndata: {}\n\n"
@app.get("/progress")
async def progress(request: Request):
return StreamingResponse(
event_stream(request),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # disable nginx buffering
"Connection": "keep-alive",
},
)
Three details that matter:
-
request.is_disconnected()— if the client closes the tab, the generator stops instead of running forever for a ghost connection. -
X-Accel-Buffering: no— if nginx sits in front, it buffers responses by default and your "real-time" feed arrives in 4KB chunks several seconds late. This header disables buffering for the stream. -
Cache-Control: no-cache— proxies must never cache a never-ending response.
The last-event-id check is the resume trick: when EventSource reconnects, it automatically sends the last id: it received, so you can continue from where the client left off instead of replaying everything.
The client: about ten lines of JavaScript
const feed = document.getElementById("feed");
const source = new EventSource("/progress");
source.addEventListener("progress", (e) => {
const data = JSON.parse(e.data);
feed.textContent = `Step ${data.step}: ${data.percent}%`;
});
source.addEventListener("done", () => {
source.close();
feed.textContent = "All done!";
});
The browser handles reconnection for you. If the connection drops, EventSource retries automatically with built-in backoff, and it sends the last id: it saw on each retry.
Pitfalls I actually hit
1. Buffering killed my first demo. It worked instantly on localhost. Deployed behind nginx — 15-second stalls. Default proxy_buffering on collects the whole stream before forwarding anything. The header above fixed it. Test behind your real proxy, not just localhost.
2. Mobile networks drop idle connections. Cellular connections can time out after 30–60 seconds of silence. If nothing happens for a while, send a comment line every 15–30 seconds to keep it alive:
: heartbeat
Comments are ignored by the client but keep the connection from being killed.
3. Reconnect storms after a deploy. When I restarted the server, every connected client reconnected at once and hammered the endpoint simultaneously. EventSource's backoff helps, but for a large fanout you want graceful connection draining on your server, and a small random delay if you control the client side. Also: don't return 500 on a disconnect — clients will retry and amplify the problem.
4. Auth is whatever your app already uses — with one catch. Cookies work for same-origin. For cross-origin you can use credentials or pass a token as a query parameter. But note that EventSource cannot set custom headers — if your API only accepts Authorization headers, you'll need a fetch-based SSE reader or a WebSocket instead.
When NOT to use SSE
- Client → server messages (chat, games): SSE is one-way.
- Binary data: SSE is text-only. Send a URL, not the bytes.
- Very high fanout (10k+ concurrent connections): one open connection per user is expensive. Consider multiplexing or a push service.
-
You need precise control over reconnection: EventSource's retry behavior is opaque. If you need fine-grained control, roll your own with
fetch+ReadableStream.
Final thoughts
SSE has been around for over a decade and is still the most underrated tool in the streaming toolbox. It's one plain HTTP response, supported by every browser, with reconnection built in, and it takes about thirty lines with FastAPI. If your use case is server-to-client events — notifications, progress, live updates, token streaming — try SSE before you reach for a WebSocket.
Top comments (0)