Chapter 24: Server-Sent Events (SSE)#
1. The Polling Problem#
Your monitoring dashboard needs live metrics. CPU usage. Memory. Active connections. The numbers change every few seconds.
The obvious solution: poll. A timer fires every three seconds. The browser sends a GET request. The server queries the database. The server responds. The browser updates the page. Repeat.
That works. It also wastes resources. Nineteen out of twenty polls return the same data. Each one opens a connection, sends headers, waits for a response, and closes the connection. The server does the same work whether the data changed or not.
WebSocket solves this. A persistent, bi-directional connection. The server pushes when data changes. But WebSocket is designed for two-way communication. Chat rooms. Collaborative editing. Live gaming. Your dashboard only needs one direction: server to client.
Server-Sent Events (SSE) is the middle ground. One-way streaming over plain HTTP. The server pushes. The client listens. The browser reconnects automatically if the connection drops. No upgrade handshake. No special protocol. Just HTTP with a twist.
2. What SSE Is#
HTTP works like this:
Client: "Give me /api/metrics"Server: "Here are the metrics" (connection closes)Client: "Give me /api/metrics" (new connection, 3 seconds later)Server: "Here are the metrics" (connection closes)WebSocket works like this:
Client: "Upgrade to WebSocket on /ws/metrics"Server: "Upgrade accepted. Connection open."Client: "Subscribe to CPU metrics"Server: "CPU: 42%"Server: "CPU: 38%" (pushed at any time)Client: "Unsubscribe"SSE works like this:
Client: "Give me /events (keep the connection open)"Server: "data: CPU 42%"Server: "data: CPU 38%" (pushed, no client request)Server: "data: CPU 45%" (keeps flowing)Client: (just listens)The key differences:
| Feature | HTTP Polling | WebSocket | SSE |
|---|---|---|---|
| Direction | Client to server | Both ways | Server to client |
| Connection | New each time | Persistent | Persistent |
| Protocol | HTTP | WebSocket (upgrade) | HTTP |
| Auto-reconnect | No | No (manual) | Yes (built-in) |
| Binary data | Yes | Yes | No (text only) |
| Browser support | Universal | Universal | Universal (except IE) |
| Complexity | Simple | Moderate | Simple |
SSE uses plain HTTP. No protocol upgrade. No special server support. The server sets Content-Type: text/event-stream and keeps the connection open. The browser's EventSource API handles reconnection, event parsing, and last-event-ID tracking.
3. Your First Stream#
Create src/routes/events.py:
import asynciofrom tina4_python import getโ@get("/events")async def stream_events(request, response): async def generate(): for i in range(10): yield f"data: {{\"count\": {i}, \"message\": \"tick\"}}\n\n" await asyncio.sleep(1) yield "data: {\"done\": true}\n\n"โ return response.stream(generate())Three things happen here:
generate()is an async generator. Eachyieldproduces one SSE message.response.stream()tells Tina4 to keep the connection open and send each chunk as it arrives.- The framework sets
Content-Type: text/event-stream,Cache-Control: no-cache, andConnection: keep-alivefor you.
Start the server and test with curl:
curl -N http://localhost:7146/eventsYou see one message per second:
data: {"count": 0, "message": "tick"}โdata: {"count": 1, "message": "tick"}โdata: {"count": 2, "message": "tick"}Each message arrives the moment the server yields it. No buffering. No waiting for the full response.
4. The SSE Protocol#
SSE messages are plain text with a simple format. Each message is one or more field lines followed by a blank line.
The data: Field#
The most common field. Contains the message payload.
data: Hello, worldโMultiple data: lines in one message get joined with newlines:
data: line onedata: line twoโThe client receives this as "line one\nline two".
The event: Field#
Names the event type. Without it, the browser fires onmessage. With it, the browser fires a named event listener.
yield "event: metrics\ndata: {\"cpu\": 42}\n\n"yield "event: alert\ndata: {\"level\": \"high\", \"message\": \"CPU spike\"}\n\n"On the client:
source.addEventListener("metrics", (e) => { const data = JSON.parse(e.data); updateDashboard(data);});โsource.addEventListener("alert", (e) => { const data = JSON.parse(e.data); showAlert(data.message);});Named events let you multiplex different data types on a single connection.
The id: Field#
Sets the last event ID. If the connection drops, the browser sends Last-Event-ID in the reconnection request. Your server can resume from where it left off.
yield f"id: {i}\ndata: {{\"count\": {i}}}\n\n"The retry: Field#
Tells the browser how many milliseconds to wait before reconnecting after a disconnect.
yield "retry: 5000\n\n" # reconnect after 5 secondsThe Delimiter#
Every SSE message ends with two newlines: \n\n. This tells the browser that the message is complete. A single \n separates fields within one message.
# One complete SSE message:yield "event: update\nid: 42\ndata: {\"value\": 100}\n\n"โ# Broken down:# event: update\n โ event type# id: 42\n โ event ID# data: {"value": 100}\n โ payload# \n โ end of message (blank line)5. Frontend: EventSource#
The browser provides EventSource for consuming SSE streams. It handles connection management, reconnection, and message parsing.
Basic Usage#
const source = new EventSource("/events");โsource.onmessage = function (event) { const data = JSON.parse(event.data); console.log("Received:", data);};โsource.onerror = function () { console.log("Connection lost. Reconnecting...");};โsource.onopen = function () { console.log("Connected");};EventSource reconnects automatically when the connection drops. The onerror handler fires, the browser waits (default 3 seconds), then reconnects. Your server receives a new request with the Last-Event-ID header if you sent event IDs.
Named Events#
const source = new EventSource("/events");โsource.addEventListener("metrics", function (event) { updateChart(JSON.parse(event.data));});โsource.addEventListener("notification", function (event) { showToast(JSON.parse(event.data));});โsource.addEventListener("heartbeat", function (event) { // Connection is alive});Closing the Connection#
source.close();The browser stops reconnecting. The server receives a client disconnect.
With Authentication#
EventSource does not support custom headers. Pass tokens as query parameters:
const token = getAuthToken();const source = new EventSource(`/events?token=${token}`);On the server:
@get("/events")async def secure_events(request, response): token = request.query.get("token") payload = Auth.valid_token(token) if not payload: return response({"error": "Unauthorized"}, 401)โ async def generate(): while True: yield f"data: {{\"user\": \"{payload['email']}\"}}\n\n" await asyncio.sleep(5)โ return response.stream(generate())6. Real Examples#
Live Dashboard#
Stream database metrics every five seconds:
import asynciofrom tina4_python import getfrom tina4_python.database import Databaseโ@get("/events/dashboard")async def dashboard_stream(request, response): async def generate(): db = Database() while True: orders = db.fetch_one("SELECT count(*) as total FROM orders") revenue = db.fetch_one("SELECT sum(total) as revenue FROM orders WHERE date(created_at) = date('now')")โ yield f"data: {{\"orders\": {orders['total']}, \"revenue\": {revenue['revenue'] or 0}}}\n\n" await asyncio.sleep(5)โ return response.stream(generate())The client updates the dashboard numbers without polling. One connection. One query every five seconds. No wasted requests.
Build Progress#
Stream deployment status as it happens:
import asyncioimport jsonfrom tina4_python import getโ@get("/events/deploy/{build_id}")async def deploy_stream(request, response): build_id = request.params["build_id"]โ async def generate(): steps = [ ("pull", "Pulling latest code..."), ("install", "Installing dependencies..."), ("test", "Running tests..."), ("build", "Building application..."), ("deploy", "Deploying to production..."), ("done", "Deployment complete"), ]โ for step, message in steps: yield f"event: progress\ndata: {json.dumps({'step': step, 'message': message, 'build_id': build_id})}\n\n" await asyncio.sleep(2) # simulate workโ return response.stream(generate())The browser shows a progress bar that updates in real time. Each step arrives as it completes. The user watches the deployment unfold instead of staring at a spinner.
LLM Chat Streaming#
Stream AI responses token by token. This is how ChatGPT works:
import asyncioimport jsonimport urllib.requestfrom tina4_python import getโ@get("/events/chat")async def chat_stream(request, response): prompt = request.query.get("q", "Hello")โ async def generate(): # Call the LLM API with streaming enabled req_data = json.dumps({ "model": "claude-sonnet-4-20250514", "max_tokens": 512, "stream": True, "messages": [{"role": "user", "content": prompt}], }).encode()โ req = urllib.request.Request( "https://api.anthropic.com/v1/messages", data=req_data, headers={ "Content-Type": "application/json", "x-api-key": os.environ["ANTHROPIC_API_KEY"], "anthropic-version": "2023-06-01", }, )โ with urllib.request.urlopen(req) as resp: for line in resp: line = line.decode().strip() if line.startswith("data: "): chunk = json.loads(line[6:]) if chunk.get("type") == "content_block_delta": text = chunk["delta"].get("text", "") yield f"data: {json.dumps({'token': text})}\n\n" await asyncio.sleep(0)โ yield "data: {\"done\": true}\n\n"โ return response.stream(generate())The frontend appends each token as it arrives:
const source = new EventSource(`/events/chat?q=${encodeURIComponent(question)}`);const output = document.getElementById("response");โsource.onmessage = function (event) { const data = JSON.parse(event.data); if (data.done) { source.close(); return; } output.textContent += data.token;};The response builds character by character. The user reads as the AI writes. No waiting for the full answer.
File Processing Progress#
Upload a CSV, stream the processing status:
import asyncioimport jsonfrom tina4_python import postโ@post("/api/import")async def import_csv(request, response): file = request.files.get("csv") if not file: return response({"error": "No file"}, 400)โ lines = file["content"].decode().strip().split("\n") total = len(lines) - 1 # minus headerโ async def generate(): for i, line in enumerate(lines[1:], 1): # Process each row cols = line.split(",") # ... insert into database ... yield f"data: {json.dumps({'processed': i, 'total': total, 'percent': round(i/total*100)})}\n\n" await asyncio.sleep(0) # yield controlโ yield f"data: {json.dumps({'done': True, 'total': total})}\n\n"โ return response.stream(generate(), content_type="text/event-stream")The browser shows a progress bar that fills as each row is processed. The user sees exactly where the import stands.
7. Custom Content Types#
SSE defaults to text/event-stream, but response.stream() accepts any content type. Use this for newline-delimited JSON (NDJSON), chunked binary, or custom protocols.
NDJSON Streaming#
import asyncioimport jsonfrom tina4_python import getโ@get("/api/export")async def export_stream(request, response): async def generate(): db = Database() result = db.fetch("SELECT * FROM products", limit=1000) for row in result.records: yield json.dumps(row) + "\n" await asyncio.sleep(0)โ return response.stream(generate(), content_type="application/x-ndjson")Each line is a complete JSON object. The client reads line by line. No need to parse a giant array. Memory stays flat regardless of how many rows you export.
Consuming NDJSON on the Client#
async function streamProducts() { const response = await fetch("/api/export"); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = "";โ while (true) { const { done, value } = await reader.read(); if (done) break;โ buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop(); // keep incomplete lineโ for (const line of lines) { if (line.trim()) { const product = JSON.parse(line); addToTable(product); } } }}8. SSE vs WebSocket#
Both keep a persistent connection. Both push data in real time. The choice depends on the direction of communication.
| Use Case | Choose | Why |
|---|---|---|
| Live dashboard | SSE | Server pushes metrics. Client only listens. |
| Chat application | WebSocket | Both sides send messages. |
| Notification feed | SSE | Server pushes. Client marks as read via separate HTTP POST. |
| Collaborative editor | WebSocket | Both sides send cursor positions and edits. |
| Build/deploy progress | SSE | Server streams status. Client watches. |
| LLM token streaming | SSE | Server streams tokens. Client displays them. |
| Online gaming | WebSocket | Low-latency, bi-directional input and state. |
| Stock ticker | SSE | Server pushes prices. Client displays. |
| File upload progress | HTTP (native) | Browser tracks upload. Server tracks processing via SSE. |
Rule of thumb: If the client only listens, use SSE. If the client sends data back on the same connection, use WebSocket.
SSE has one advantage WebSocket does not: automatic reconnection. The browser's EventSource reconnects without any code. WebSocket requires manual reconnection logic (or frond.js which handles it for you).
9. Production Considerations#
Nginx Proxy Buffering#
Nginx buffers responses by default. SSE messages accumulate in the buffer and arrive in batches instead of one at a time. Tina4 sets X-Accel-Buffering: no on streaming responses, which tells Nginx to disable buffering for that response.
If you configure Nginx manually:
location /events/ { proxy_pass http://localhost:7146; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off;}Connection Limits#
Each SSE connection is a persistent HTTP connection. Most servers limit concurrent connections. The default asyncio server handles thousands. In production:
- Monitor open connections
- Set timeouts on long-running streams
- Close streams when the client no longer needs them
@get("/events/metrics")async def metrics_with_timeout(request, response): async def generate(): for _ in range(3600): # max 1 hour (one message per second) yield f"data: {{\"cpu\": {get_cpu()}}}\n\n" await asyncio.sleep(1) yield "event: timeout\ndata: {\"message\": \"Stream expired. Reconnect.\"}\n\n"โ return response.stream(generate())Heartbeat Messages#
Some proxies and load balancers close idle connections after a timeout (typically 30-60 seconds). Send a heartbeat comment to keep the connection alive:
async def generate(): counter = 0 while True: if has_new_data(): yield f"data: {json.dumps(get_data())}\n\n" else: yield ": heartbeat\n\n" # SSE comment (colon prefix) - ignored by EventSource counter += 1 await asyncio.sleep(5)Lines starting with : are SSE comments. The browser ignores them, but they keep the connection alive through proxies.
10. Exercise: Build a Live Metrics Dashboard#
Build a dashboard that streams server metrics to the browser.
Requirements#
- SSE endpoint at
GET /events/server-metricsthat streams every 3 seconds:- Current timestamp
- A random CPU percentage (simulate with
random.randint(10, 90)) - A random memory percentage
- A random request count
- HTML page at
GET /dashboardthat:- Connects to the SSE endpoint
- Displays the four metrics in cards
- Updates the values in real time (no page refresh)
- Shows a "Connected" / "Reconnecting..." status indicator
Test by:#
- Open
http://localhost:7146/dashboard - Watch the numbers update every 3 seconds
- Stop the server and verify "Reconnecting..." appears
- Restart the server and verify it reconnects and resumes updating
11. Solution#
Create src/routes/server_metrics.py:
import asyncioimport jsonimport randomfrom datetime import datetime, timezonefrom tina4_python import getโ@get("/events/server-metrics")async def server_metrics_stream(request, response): async def generate(): yield "retry: 3000\n\n" counter = 0 while True: yield f"id: {counter}\ndata: {json.dumps({ 'timestamp': datetime.now(timezone.utc).isoformat(), 'cpu': random.randint(10, 90), 'memory': random.randint(30, 85), 'requests': random.randint(100, 5000), })}\n\n" counter += 1 await asyncio.sleep(3)โ return response.stream(generate())โโ@get("/dashboard")async def dashboard_page(request, response): html = """ <!DOCTYPE html> <html> <head><title>Live Dashboard</title></head> <body style="font-family: system-ui; max-width: 600px; margin: 40px auto; padding: 0 20px;"> <h1>Server Metrics</h1> <p id="status" style="color: #888;">Connecting...</p> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 20px;"> <div style="background: #f5f5f5; padding: 20px; border-radius: 8px;"> <div style="color: #888; font-size: 14px;">CPU</div> <div id="cpu" style="font-size: 32px; font-weight: 700;">--</div> </div> <div style="background: #f5f5f5; padding: 20px; border-radius: 8px;"> <div style="color: #888; font-size: 14px;">Memory</div> <div id="memory" style="font-size: 32px; font-weight: 700;">--</div> </div> <div style="background: #f5f5f5; padding: 20px; border-radius: 8px;"> <div style="color: #888; font-size: 14px;">Requests/min</div> <div id="requests" style="font-size: 32px; font-weight: 700;">--</div> </div> <div style="background: #f5f5f5; padding: 20px; border-radius: 8px;"> <div style="color: #888; font-size: 14px;">Last Update</div> <div id="time" style="font-size: 16px; font-weight: 500;">--</div> </div> </div> <script> const source = new EventSource("/events/server-metrics"); const status = document.getElementById("status");โ source.onopen = function () { status.textContent = "Connected"; status.style.color = "#22c55e"; };โ source.onmessage = function (event) { const data = JSON.parse(event.data); document.getElementById("cpu").textContent = data.cpu + "%"; document.getElementById("memory").textContent = data.memory + "%"; document.getElementById("requests").textContent = data.requests.toLocaleString(); document.getElementById("time").textContent = new Date(data.timestamp).toLocaleTimeString(); };โ source.onerror = function () { status.textContent = "Reconnecting..."; status.style.color = "#ef4444"; }; </script> </body> </html> """ return response(html)Open http://localhost:7146/dashboard. The numbers update every three seconds. Stop the server. The status turns red: "Reconnecting..." Start it again. The status turns green. The numbers resume.
No polling. No WebSocket. No JavaScript timers. The browser handles everything.
12. What You Learned#
- SSE streams data from server to client over a single HTTP connection. No upgrade. No special protocol.
response.stream(generator)keeps the connection open and flushes each chunk as the generator yields it.- The SSE protocol uses
data:,event:,id:, andretry:fields. Messages end with\n\n. EventSourceon the client handles connection, parsing, and automatic reconnection.- Named events let you multiplex different data types on one stream.
- Custom content types let you stream NDJSON, binary, or any format.
- SSE is for one-way server pushes. Use WebSocket when the client needs to send data back on the same connection.
- Heartbeat comments keep connections alive through proxies. Tina4 sets
X-Accel-Buffering: noto disable nginx buffering.
One direction. One connection. Real-time data. The server speaks. The client listens. The rest is infrastructure.