Skip to content

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:

FeatureHTTP PollingWebSocketSSE
DirectionClient to serverBoth waysServer to client
ConnectionNew each timePersistentPersistent
ProtocolHTTPWebSocket (upgrade)HTTP
Auto-reconnectNoNo (manual)Yes (built-in)
Binary dataYesYesNo (text only)
Browser supportUniversalUniversalUniversal (except IE)
ComplexitySimpleModerateSimple

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:

python
import asyncio
from 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:

  1. generate() is an async generator. Each yield produces one SSE message.
  2. response.stream() tells Tina4 to keep the connection open and send each chunk as it arrives.
  3. The framework sets Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive for you.

Start the server and test with curl:

bash
curl -N http://localhost:7145/events

You 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 one
data: 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.

python
yield "event: metrics\ndata: {\"cpu\": 42}\n\n"
yield "event: alert\ndata: {\"level\": \"high\", \"message\": \"CPU spike\"}\n\n"

On the client:

javascript
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.

python
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.

python
yield "retry: 5000\n\n"  # reconnect after 5 seconds

The 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.

python
# 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

javascript
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

javascript
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

javascript
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:

javascript
const token = getAuthToken();
const source = new EventSource(`/events?token=${token}`);

On the server:

python
@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:

python
import asyncio
from tina4_python import get
from 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:

python
import asyncio
import json
from 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:

python
import asyncio
import json
import urllib.request
from 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:

javascript
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:

python
import asyncio
import json
from 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

python
import asyncio
import json
from 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

javascript
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 CaseChooseWhy
Live dashboardSSEServer pushes metrics. Client only listens.
Chat applicationWebSocketBoth sides send messages.
Notification feedSSEServer pushes. Client marks as read via separate HTTP POST.
Collaborative editorWebSocketBoth sides send cursor positions and edits.
Build/deploy progressSSEServer streams status. Client watches.
LLM token streamingSSEServer streams tokens. Client displays them.
Online gamingWebSocketLow-latency, bi-directional input and state.
Stock tickerSSEServer pushes prices. Client displays.
File upload progressHTTP (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:

nginx
location /events/ {
    proxy_pass http://localhost:7145;
    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
python
@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:

python
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

  1. SSE endpoint at GET /events/server-metrics that streams every 3 seconds:

    • Current timestamp
    • A random CPU percentage (simulate with random.randint(10, 90))
    • A random memory percentage
    • A random request count
  2. HTML page at GET /dashboard that:

    • 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:

  1. Open http://localhost:7145/dashboard
  2. Watch the numbers update every 3 seconds
  3. Stop the server and verify "Reconnecting..." appears
  4. Restart the server and verify it reconnects and resumes updating

11. Solution

Create src/routes/server_metrics.py:

python
import asyncio
import json
import random
from datetime import datetime, timezone
from 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:7145/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:, and retry: fields. Messages end with \n\n.
  • EventSource on 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: no to disable nginx buffering.

One direction. One connection. Real-time data. The server speaks. The client listens. The rest is infrastructure.

Sponsored with 🩵 by Code InfinityCode Infinity