Server-Sent Events (SSE) are often misunderstood as just a way to "push" data, but their real magic is how they fundamentally simplify state synchronization between a server and a client.

Imagine you’re building a live stock ticker. You have a FastAPI backend that’s constantly receiving price updates. You want clients to see these updates instantly without them having to constantly poll your API. SSE is your tool for this.

Here’s a basic FastAPI endpoint using SSE:

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

app = FastAPI()

async def event_generator(request: Request):
    count = 0
    while True:
        if await request.is_disconnected():
            print("Client disconnected")
            break
        yield f"data: Current count: {count} at {time.time()}\n\n"
        count += 1
        await asyncio.sleep(1) # Wait for 1 second before sending next event

@app.get("/stream-events")
async def stream_events(request: Request):
    return StreamingResponse(event_generator(request), media_type="text/event-stream")

And on the client-side (using JavaScript in a browser):

const eventSource = new EventSource("http://localhost:8000/stream-events");

eventSource.onmessage = function(event) {
    console.log("Received message:", event.data);
};

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

When you run this, the browser will establish a persistent HTTP connection. The server, via StreamingResponse and our event_generator coroutine, will continuously send messages. Each message is formatted according to the SSE specification: data: your message\n\n. The \n\n signifies the end of an event. The request.is_disconnected() check is crucial for gracefully handling clients that close their connection.

The core problem SSE solves is the inefficiency of traditional polling. Without SSE, your client would repeatedly ask the server, "Got any new data yet?" This leads to:

  • High Latency: Data is only delivered when the client polls, and there’s a delay until the next poll.
  • Wasted Resources: Many requests might return no new data, consuming server and network overhead for no reason.
  • Scalability Issues: As the number of clients and polling frequency increase, the server can become overwhelmed.

SSE, on the other hand, uses a single, long-lived HTTP connection. The server pushes data whenever it’s available. This is architecturally simpler than WebSockets for unidirectional data flow, as it leverages standard HTTP and requires less complex state management on both client and server.

The media_type="text/event-stream" is key. It tells the browser to interpret the incoming data as SSE events. The browser’s EventSource API handles the connection management, automatic retries on disconnect, and parsing of events.

A common pitfall is forgetting the double newline (\n\n) after each data: line. This is the delimiter the EventSource API uses to recognize the end of a complete event. Without it, the browser will buffer indefinitely, waiting for the event to finish. Another is not handling is_disconnected(). If a client closes their browser or navigates away, the server’s asyncio.sleep will block indefinitely without this check, leading to resource leaks.

When dealing with multiple clients, you don’t just loop through a single generator. Instead, you typically manage a list of connected clients, often using a shared queue or pub/sub mechanism (like Redis Pub/Sub or a simple asyncio queue) to broadcast new data to all active StreamingResponse generators. Each generator would then yield events to its specific client.

You can also send different types of events by prefixing the data: line with event:. For example, event: user_update\ndata: {"id": 123, "name": "Alice"}\n\n. The client can then listen for specific event types using eventSource.addEventListener("user_update", function(event) { ... });.

One subtlety often overlooked is how SSE handles retries. When a connection drops, the EventSource API automatically attempts to reconnect. By default, it waits a small, random amount of time before retrying. You can control this behavior by sending a retry: field in your event payload, e.g., retry: 5000\ndata: Some data\n\n. This tells the client to wait 5000 milliseconds (5 seconds) before attempting to reconnect. This is incredibly useful for managing server load during temporary network outages.

The next hurdle you’ll likely encounter is managing the state and broadcast of events across many concurrent connections efficiently, especially when your data source is also dynamic.

Want structured learning?

Take the full Fastapi course →