Real-Time APIs · Real-Time Transports
Server-Sent Events
Simple, HTTP-native server push with automatic reconnection.
SSE streams text/event-stream responses: each event has optional id, event (type), retry and data lines. Browsers' EventSource reconnects automatically and sends Last-Event-ID so servers can resume. SSE works over HTTP/1.1 and HTTP/2 (multiplexed — avoiding the old 6-connections-per-domain limit), passes through most proxies (disable buffering), and is the standard transport for streaming LLM responses.
SSE vs WebSockets
| Criterion | SSE | WebSockets |
|---|---|---|
| Direction | Server → client | Both |
| Reconnect + resume | Built in (Last-Event-ID) | Implement yourself |
| Custom headers from browser | No (EventSource); use cookies or fetch-based clients | Limited |
| Binary data | No (text) | Yes |
| Proxy/CDN compatibility | High | Varies |
app.get("/events", async (req, res) => {
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", "X-Accel-Buffering": "no" });
res.flushHeaders();
const since = req.get("Last-Event-ID");
for (const e of await events.since(req.user.id, since)) res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`);
const unsubscribe = bus.subscribe(req.user.id, (e) => res.write(`id: ${e.id}\nevent: ${e.type}\ndata: ${JSON.stringify(e.data)}\n\n`));
const ping = setInterval(() => res.write(": ping\n\n"), 15_000);
req.on("close", () => { clearInterval(ping); unsubscribe(); });
});
@app.get("/events")
async def events(request: Request, user: CurrentUser, last_event_id: str | None = Header(None)):
async def stream():
for e in await store.since(user.id, last_event_id): # resume after reconnect
yield f"id: {e.id}\nevent: {e.type}\ndata: {json.dumps(e.data)}\n\n"
async for e in bus.subscribe(user.id):
if await request.is_disconnected():
break
yield f"id: {e.id}\nevent: {e.type}\ndata: {json.dumps(e.data)}\n\n"
return StreamingResponse(stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
const es = new EventSource("/events", { withCredentials: true });
es.addEventListener("order.updated", (e) => render(JSON.parse(e.data)));
es.onerror = () => console.warn("reconnecting…"); // EventSource retries automatically
Common pitfalls
- Proxy buffering delays events (disable it).
- Not storing events for Last-Event-ID resume.
- HTTP/1.1 connection limits with many tabs (prefer HTTP/2).
Key points
- text/event-stream with ids
- Automatic reconnect + resume
- Disable buffering; send keep-alives
Further reading: HTML Living Standard: Server-sent events
Practice questions
How does SSE resume after a dropped connection?
Answer
EventSource reconnects automatically and sends the last received event ID in the Last-Event-ID header; the server replays events after that ID.
Why must you disable proxy buffering for SSE?
Answer
Buffering proxies (e.g., NGINX) hold the response until it's complete or a buffer fills, so events wouldn't reach the client in real time.
Open in the app to mark lessons complete, take notes and track your progress.