Right, so you want to push data from your server to your client. You’ve probably heard of WebSockets, and they’re great for a full-duplex chat application. But what if your problem is simpler? What if the server just needs to talk to the client, and the client just needs to listen? That’s where Server-Sent Events (SSE) comes in. It’s the less-famous, hyper-efficient, “I-have-one-job-and-I-do-it-perfectly” cousin of WebSockets. It uses a simple, long-lived HTTP connection, and it’s built right into the browser with the EventSource API. No need for a hefty library. It’s elegant, it’s simple, and it’s tragically underused.

The Beautifully Simple Protocol

Forget complex handshakes and framing protocols. SSE is just HTTP, with a specific content type and a simple, text-based format. The magic header is Content-Type: text/event-stream. The data is sent in blocks, separated by two newlines (\n\n). Each block can have a few fields, but the only one you absolutely need is data.

Here’s what a raw SSE response from your server might look like:

event: status
data: System is starting up. Hold onto your butts.

data: {"message": "User 42 logged in", "timestamp": 1718654400}

: This is a comment line. The client ignores it.
id: 12345
retry: 5000
data: Here is a message with an ID and a suggested retry timeout.

The client’s EventSource parses this stream, splits it into events, and fires them as messages. The id field helps with reconnection; if the connection drops, the browser will automatically reconnect and send a Last-Event-ID header so you can potentially pick up where you left off. The retry field (in milliseconds) is a suggestion to the client on how long to wait before reconnecting. It’s a wonderfully human-readable protocol.

Implementing SSE in Flask (The “It’s Fine, I Guess” Way)

Flask can do SSE, but you have to understand its core assumption: views are short-lived. To break free, we use a generator function that yields data. We also have to disable buffering to ensure data is sent immediately.

from flask import Flask, Response, request
import json
import time

app = Flask(__name__)

def event_stream():
    """A generator that yields formatted SSE messages."""
    count = 0
    try:
        while True:
            count += 1
            # Simulate some work
            time.sleep(1)
            # The format is critical: "data: {content}\n\n"
            yield f"data: {json.dumps({'count': count, 'msg': 'Hello'})}\n\n"
    except GeneratorExit:
        print("Client disconnected. Stopping stream.")

@app.route('/stream')
def stream():
    # Set the headers to tell the client this is an SSE stream
    return Response(
        event_stream(),
        mimetype='text/event-stream',
        headers={
            'Cache-Control': 'no-cache',
            'X-Accel-Buffering': 'no'  # For Nginx, but good practice
        }
    )

if __name__ == '__main__':
    app.run(threaded=True)

The client-side is even simpler:

const eventSource = new EventSource('http://localhost:5000/stream');

eventSource.onmessage = function(event) {
    const data = JSON.parse(event.data);
    console.log('New message:', data);
};

eventSource.onerror = function(err) {
    console.error("EventSource failed:", err);
};

The Big Flask Caveat: This works, but it’s… naive. Each connected client blocks a worker thread. For a small, internal tool, it’s fine. For anything with more than a handful of users, it’s a recipe for disaster. Flask’s development server is especially bad at this. For a production-grade Flask SSE setup, you’d need to use an async worker like gevent or funnel everything through a proper message broker (Redis Pub/Sub) and a separate background process. It gets messy fast.

Implementing SSE in FastAPI (The “Oh, This is Actually Good” Way)

FastAPI, being an async-native framework, is where SSE truly shines. It handles long-lived connections effortlessly without blocking, thanks to async and the brilliant starlette.streams it’s built upon. This is the right tool for the job.

from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
import asyncio
import json
import time

app = FastAPI()

async def fake_data_generator(request: Request):
    """An async generator that produces SSE events."""
    try:
        while True:
            # Check if the client has disconnected
            if await request.is_disconnected():
                print("Client disconnected. Stopping generation.")
                break

            # Simulate an async operation (e.g., waiting on a queue)
            await asyncio.sleep(1)

            # Create our data payload
            data_payload = {"time": time.time(), "message": "Async hello!"}

            # Format it according to the SSE spec.
            # The double newline is crucial.
            yield f"data: {json.dumps(data_payload)}\n\n"

    except asyncio.CancelledError:
        print("Client connection was cancelled.")

@app.get("/stream")
async def stream_data(request: Request):
    response = StreamingResponse(
        fake_data_generator(request),
        media_type="text/event-stream",
    )
    response.headers["Cache-Control"] = "no-cache"
    response.headers["X-Accel-Buffering"] = "no"
    return response

The client code remains exactly the same. The beauty here is that asyncio.sleep(1) isn’t blocking the entire process; it’s yielding control, allowing other connections to be handled. FastAPI gracefully handles client disconnections, and the whole thing feels robust and production-ready from the start.

Common Pitfalls and The “Gotchas”

  1. The Two Newlines (\n\n): This is the most common mistake. You must end your event with two newline characters. One \n after your data: line isn’t enough. The client will buffer the message until it sees the second one.
  2. Proxy Buffering: Many proxies (like Nginx) will buffer responses by default to optimize for traditional HTTP. This will hold up your SSE messages, defeating the entire purpose of real-time. You must set proxy_buffering off; for your SSE endpoint in your Nginx config or include the X-Accel-Buffering: no header as we did.
  3. Authentication is Tricky: The standard EventSource object doesn’t let you set custom headers. So, if your authentication relies on a Bearer token in the Authorization header, you’re stuck. Your workarounds are to use a query parameter (ick) or use a more flexible library like fetch and parse the stream manually, which defeats the simplicity.
  4. It’s One-Way: Remember, this is a server-to-client channel. If the client needs to talk back, it needs to use a separate API (e.g., a regular HTTP POST endpoint). This isn’t a limitation, it’s a design feature. It keeps the complexity where it belongs.