Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 099 / 100

Server-sent events in FastAPI: best practices explained

Stream updates to browsers without WebSockets.

Expona Admin7 min read

We've all been there: a feature request comes in for real-time notifications, progress bars, or live AI output streaming, and the team's first instinct is to reach for WebSockets. But WebSockets add complexity—bidirectional messaging, connection pooling, stateful servers. Server-sent events in FastAPI offer a simpler path when you only need one-way updates flowing from server to browser. This article walks through best practices for implementing SSE, from deciding when it fits your use case through production deployment behind proxies.

When SSE fits — One-way updates: progress, notifications, AI streaming

Server-sent events (SSE) are HTTP-based, using the EventSource API in the browser to listen for streaming responses. Unlike WebSockets, SSE is unidirectional by design: the server pushes data to connected clients, and clients send requests only if they need to send commands back. This simplicity makes SSE ideal for several common patterns.

Progress updates are a natural fit. When a user uploads a large file or triggers a long-running job, you stream progress percentages, current step, and ETA directly to the browser. The client only needs to listen; there's no back-and-forth negotiation.

Notifications work well too. Think order status changes, alert triggers, or real-time feed updates. Each notification is a discrete event the server pushes out. Clients subscribe via a simple HTTP GET and receive updates as they arrive.

AI streaming has become increasingly common. When you call an LLM with streaming enabled, you get chunks back one at a time. SSE lets you forward those chunks directly to the browser in real time. The client sees the response building word by word, which feels more responsive than waiting for the full completion.

Where SSE doesn't fit: if you need bidirectional communication (client sends commands mid-stream), or if you need to handle massive fan-out (thousands of subscribers to a single stream), WebSockets or a dedicated message queue becomes necessary. SSE shines when the pattern is asymmetric: lots of concurrent listeners, but minimal back-and-forth.

StreamingResponse — Yield data: lines from an async generator

FastAPI's StreamingResponse is your primary tool for SSE. Instead of returning a JSON object, you return a StreamingResponse with a generator that yields SSE-formatted lines.

The SSE protocol is simple: events are plain text, with each line prefixed by a field name and a colon. Here's the structure:

python
data: {"status": "processing", "progress": 25}
retry: 5000

data: {"status": "processing", "progress": 50}
event: progress

data: {"status": "done"}
event: complete

In FastAPI, here's a minimal example:

python
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from typing import AsyncGenerator

app = FastAPI()

async def event_generator(job_id: str) -> AsyncGenerator[str, None]:
    """Yield SSE-formatted messages for a long-running job."""
    # Simulate job progress
    for i in range(1, 101, 10):
        data = {
            "job_id": job_id,
            "progress": i,
            "status": "processing" if i < 100 else "done"
        }
        yield f"data: {json.dumps(data)}\n\n"
        # In real code, await some async work here
        await asyncio.sleep(1)

@app.get("/stream-progress/{job_id}")
async def stream_progress(job_id: str):
    return StreamingResponse(
        event_generator(job_id),
        media_type="text/event-stream"
    )

On the browser side, the EventSource API handles the listening:

python
const eventSource = new EventSource('/stream-progress/12345');

eventSource.addEventListener('message', (event) => {
    const data = JSON.parse(event.data);
    console.log('Progress:', data.progress);
});

eventSource.addEventListener('error', () => {
    console.log('Connection lost or error occurred');
    eventSource.close();
});

Notice that we don't explicitly close the connection after completion. The server simply stops yielding data, and the client's connection closes naturally. The browser won't hammer the server retrying a closed connection—EventSource respects HTTP semantics.

For AI streaming, the pattern is almost identical. Instead of yield iterations over a progress loop, you yield chunks from an LLM stream:

python
async def ai_stream_generator(prompt: str) -> AsyncGenerator[str, None]:
    """Stream tokens from an LLM."""
    # Example using OpenAI's async client
    async with openai.AsyncOpenAI() as client:
        async with client.chat.completions.create(
            model="gpt-4",
            messages=[{"role": "user", "content": prompt}],
            stream=True,
        ) as response:
            async for chunk in response:
                token = chunk.choices[0].delta.content or ""
                # SSE format: plain text field for simplicity
                yield f"data: {token}\n\n"

@app.post("/stream-ai")
async def stream_ai(body: dict):
    return StreamingResponse(
        ai_stream_generator(body["prompt"]),
        media_type="text/event-stream"
    )

The key is remembering that each SSE line must end with \n\n (double newline). A single newline separates fields; the double newline marks the end of an event. Omit it and the browser buffers, waiting for more data.

Reconnect built in — Browsers reconnect automatically with EventSource

One of EventSource's strengths is automatic reconnection. If the network drops or the server closes the connection, EventSource will retry after a delay—no client-side code needed.

You control the retry interval by sending a retry field:

python
async def event_generator_with_retry(job_id: str) -> AsyncGenerator[str, None]:
    """Yield events with explicit retry timing."""
    try:
        for i in range(100):
            data = {"progress": i, "message": f"Step {i}"}
            yield f"retry: 3000\n"  # Retry after 3 seconds if connection drops
            yield f"data: {json.dumps(data)}\n\n"
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        # Connection closed by client or network issue
        # Server will naturally stop yielding; EventSource will retry
        pass

By default, browsers retry after 3 seconds. If you want longer waits between retries on reconnect, send retry: 10000 for 10 seconds. Shorter retries feel snappier but cost more server resources if you're dealing with network instability.

There's a subtle but important point: if the client disconnects (e.g., the user closes the page), the server's async generator will eventually be garbage collected and stopped. You don't need explicit cleanup code in most cases, but for long-lived streams, it's good practice to watch for asyncio.CancelledError and clean up resources (database cursors, file handles, etc.).

Behind proxies — Disable buffering in Nginx for streaming endpoints

SSE works beautifully on localhost or over direct connections, but production often sits behind reverse proxies like Nginx or load balancers. These proxies, by default, buffer responses. Your first event might sit in the proxy buffer for several seconds before reaching the browser—defeating the purpose of real-time streaming.

The fix is to disable buffering for SSE endpoints. In Nginx, add these headers to your upstream FastAPI configuration:

python
location /stream-progress {
    proxy_pass http://fastapi_backend;
    
    # Disable all buffering for streaming
    proxy_buffering off;
    proxy_request_buffering off;
    
    # Prevent upstream timeouts for long-lived connections
    proxy_connect_timeout 7d;
    proxy_send_timeout 7d;
    proxy_read_timeout 7d;
    
    # Don't close connection on upgrade (relevant for protocol negotiation)
    proxy_http_version 1.1;
    proxy_set_header Connection "";
}

The timeout values seem extreme, but they're necessary. A single event might take hours to arrive (think of a rare alert scenario), and you don't want the proxy closing the connection while waiting.

In FastAPI itself, ensure your application isn't buffering either. StreamingResponse respects the streaming contract by default, but if you're wrapping or modifying the response, verify you're not collecting chunks into a list before yielding.

For AWS ALB (Application Load Balancer), deactivate connection draining and set appropriate idle timeouts. For CloudFlare or similar CDNs, be aware that they may buffer by default; check their documentation for disabling it on specific paths.

Common mistakes

Forgetting the double newline. data: {json}\n is incomplete; it must be data: {json}\n\n. Browsers won't recognize the event without it, and you'll see buffering issues.

Yielding JSON without encoding it. A Python dict is not valid SSE. Always use json.dumps() and encode to string before yielding.

Not handling connection drops gracefully. If your async generator raises an unhandled exception, FastAPI closes the connection abruptly. Wrap your generator in try/except or rely on asyncio.CancelledError for cleanup.

Forgetting proxy configuration. Testing locally works; deploy behind Nginx and events arrive in bursts or with delays. Always disable buffering in production proxies.

Mixing SSE with bidirectional messaging. EventSource is one-way. If you need the client to send commands during the stream, you need WebSockets or a separate request channel.

Key takeaways

  • Use SSE for one-way server-to-browser streaming: progress bars, notifications, and AI token streaming are ideal use cases.
  • StreamingResponse with an async generator is the FastAPI pattern; ensure each event ends with \n\n.
  • EventSource in the browser handles reconnection automatically; configure retry timing with the retry field.
  • Production deployments behind Nginx or ALB require explicit buffering disablement and timeout configuration.
  • Test streaming endpoints behind your actual proxy setup before deploying; localhost behavior differs significantly from production.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles