Streaming, JSON Lines & SSE

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on 2026-10-02. All examples use Annotated where a parameter needs one, run on Python 3.10 or newer and were executed on Python 3.13 — with TestClient where the response is finite, and against a real Uvicorn server (with httpx, curl and Node.js) where the point is the live connection: disconnects, reconnects and mid-stream failures. A normal response is computed completely and then sent. A streaming response is sent piece by piece while the server is still producing it: a large export that must not be loaded into memory, one record per line from a slow query, tokens from a language model, or a feed that never ends. FastAPI has three levels for this, from lowest to highest: StreamingResponse (bytes or text, nothing else assumed), JSON Lines (one validated JSON document per line, 0.134.0) and Server-Sent Events (a standard browsers speak natively, 0.135.0). For the sibling topics see Custom Responses, Response Models & Return Types, Dependency Injection and WebSockets.

Four Ways to Send a Body in Pieces

All of them use HTTP chunked transfer (HTTP/1.1) or DATA frames (HTTP/2): the server does not know the final size, so it sends no Content-Length and the client reads until the stream ends. In ASGI terms the application sends http.response.start once and then any number of http.response.body messages with more_body set to true. Because the status line and headers go out with the first chunk, a status code can no longer change once streaming has started (see Errors in the Middle of a Stream).

Table 1. Streaming options at a glance
Option Wire format Typical use Notes

StreamingResponse

Any bytes or text you produce

File downloads, CSV exports, text from an LLM, video or audio

You own encoding and Content-Type. No validation, no OpenAPI item schema.

JSON Lines

application/jsonl, one JSON document per line

Logs, telemetry, progressive result sets for programmatic clients

0.134.0. Items validated and serialized with Pydantic when the return type is declared.

Server-Sent Events

text/event-stream, data:/event:/id:/retry: fields

Notifications and live feeds for browsers, AI chat, progress

0.135.0. EventSource reconnects automatically and resumes with Last-Event-ID. Works with any HTTP method on the server.

WebSockets

Bidirectional frames after an upgrade

Chat, collaborative editing, anything the client sends to continuously

Different protocol and lifecycle; see WebSockets.

StreamingResponse and Generators

StreamingResponse comes from Starlette and is re-exported by fastapi.responses. Its constructor takes content (a sync or async iterable of bytes or str), status_code, headers, media_type and background. A sync iterable is consumed in a worker thread, one next() per chunk, so blocking reads do not stall the event loop; an async iterable runs on the event loop and must not block. Text chunks are encoded with the response charset (UTF-8).

The Explicit Form

You can always build the response yourself and return it. This works in every FastAPI version and is the right choice when you need to set headers or the status code per request. The example exports the Bookshelf catalog as CSV without ever holding the file in memory, reading it in 64 KiB chunks:

import tempfile
from collections.abc import AsyncIterable, Iterable
from pathlib import Path

import anyio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient

CHUNK = 64 * 1024
tmp = Path(tempfile.mkdtemp()) / "catalog.csv"
tmp.write_bytes(b"id,title\n" + b"".join(b"%d,Book %d\n" % (i, i) for i in range(20000)))

app = FastAPI()


def iter_file(path: Path) -> Iterable[bytes]:
    with path.open("rb") as f:
        while chunk := f.read(CHUNK):
            yield chunk


@app.get("/exports/books.csv")
def export_books() -> StreamingResponse:
    return StreamingResponse(
        iter_file(tmp),
        media_type="text/csv",
        headers={"Content-Disposition": 'attachment; filename="books.csv"',
                 "Content-Length": str(tmp.stat().st_size)},
    )

Two details matter. The with block lives inside the generator, so the file is closed when the generator finishes — or is closed because the client left. And Content-Length is optional: setting it (when you know the size) lets the browser show a progress bar; leaving it out gives chunked transfer.

The yield Form (0.134.0 and Later)

Since FastAPI 0.134.0 (2026-02-27) a path operation function may itself be a generator. Declare response_class=StreamingResponse and yield each chunk; FastAPI hands the chunks to the response unchanged, without converting them to JSON. Both def (run in the threadpool) and async def work; the return annotation is optional and is used only by your editor.

@app.get("/exports/books-async.csv", response_class=StreamingResponse)
async def export_books_async() -> AsyncIterable[bytes]:
    async with await anyio.open_file(tmp, "rb") as f:
        while chunk := await f.read(CHUNK):
            yield chunk


@app.get("/exports/books-sync.csv", response_class=StreamingResponse)
def export_books_sync() -> Iterable[bytes]:
    yield from iter_file(tmp)

Plain file objects have no await f.read(), so the async version uses AnyIO’s open_file, which runs the blocking calls in a worker thread; the def version simply relies on FastAPI running the generator in the threadpool. Choose def for ordinary file or database reads and async def only when every awaited call is truly non-blocking.

Setting the Content-Type

With only response_class=StreamingResponse the response has no Content-Type header, because the class does not know what you stream. Subclass it and set media_type, then use the subclass as the response class:

class CSVStreamingResponse(StreamingResponse):
    media_type = "text/csv"


@app.get("/exports/books-typed.csv", response_class=CSVStreamingResponse)
def export_books_typed() -> Iterable[bytes]:
    yield from iter_file(tmp)


c = TestClient(app)
size = tmp.stat().st_size
for path in ("/exports/books.csv", "/exports/books-async.csv", "/exports/books-sync.csv", "/exports/books-typed.csv"):
    r = c.get(path)
    print(path, r.status_code, r.headers.get("content-type"), r.headers.get("content-length"), len(r.content) == size)

Output:

/exports/books.csv 200 text/csv; charset=utf-8 317789 True
/exports/books-async.csv 200 None None True
/exports/books-sync.csv 200 None None True
/exports/books-typed.csv 200 text/csv; charset=utf-8 None True

The explicit form and the subclass both carry a media type; the bare yield forms do not. TestClient collects the whole body before returning, so it proves the bytes are right but cannot show chunk boundaries; use a real server and curl -N to watch chunks arrive.

Streaming Text, for Example Model Output

Strings are fine too. The generator below stands in for a language model producing a summary token by token; the client can render each token as soon as it arrives.

import asyncio


@app.get("/summaries/stream", response_class=StreamingResponse)
async def stream_summary() -> AsyncIterable[str]:
    for token in ["Dune", " is", " a", " science-fiction", " classic", "."]:
        await asyncio.sleep(0)  # stand in for the model producing the next token
        yield token

Concatenating the chunks gives Dune is a science-fiction classic. For token streams consumed by a browser application, SSE (below) is usually the better choice because it brings framing, reconnection and a ready-made client.

FileResponse or StreamingResponse?

FileResponse StreamingResponse

Source

A path on disk

Anything you can iterate: files, queries, generated data, another HTTP response

Headers it adds

Content-Length, Last-Modified, ETag, Accept-Ranges; handles Range requests

None (you add what you need)

Typical choice

Static downloads, images, PDFs

Data that is generated, transformed or too dynamic for a path

Pick FileResponse whenever the bytes already exist as a file; reach for StreamingResponse when they do not.

JSON Lines by yield (0.134.0)

JSON Lines is a format where each line is one JSON document, with the media type application/jsonl. Compared with a JSON array it can be produced and consumed item by item: the client processes line one while the server computes line two, and an interrupted transfer still leaves every complete line usable. FastAPI 0.134.0 turned this into a language feature: a path operation function that `yield`s and has no special response class streams JSON Lines.

import json
from collections.abc import AsyncIterable, Iterable

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel


class Book(BaseModel):
    id: int
    title: str
    author: str | None = None


BOOKS = [Book(id=1, title="Dune", author="Frank Herbert"), Book(id=2, title="Emma"),
         Book(id=3, title="Ubik", author="Philip K. Dick")]

app = FastAPI()


@app.get("/books/stream")
async def stream_books() -> AsyncIterable[Book]:
    for book in BOOKS:
        yield book


@app.get("/books/stream-sync")
def stream_books_sync() -> Iterable[Book]:
    yield from BOOKS


@app.get("/books/stream-untyped")
async def stream_untyped():
    yield {"id": 9, "title": "Untyped", "extra": [1, 2]}


c = TestClient(app)
r = c.get("/books/stream")
print(r.status_code, r.headers["content-type"])
print(r.text, end="")
print([json.loads(line)["title"] for line in r.text.splitlines()])
print(c.get("/books/stream-sync").text == r.text)
print(c.get("/books/stream-untyped").text, end="")

Output:

200 application/jsonl
{"id":1,"title":"Dune","author":"Frank Herbert"}
{"id":2,"title":"Emma","author":null}
{"id":3,"title":"Ubik","author":"Philip K. Dick"}
['Dune', 'Emma', 'Ubik']
True
{"id": 9, "title": "Untyped", "extra": [1, 2]}

How the three variants differ:

  • → AsyncIterable[Book] on an async def, or → Iterable[Book] on a def: the item type is the response model. Every yielded item is validated, filtered (fields not in Book are dropped) and serialized by Pydantic’s Rust core, which is much faster than the untyped path.

  • No annotation: still JSON Lines, but items are encoded with the generic JSON encoder; nothing is validated or documented, and the lines contain spaces after separators (see the last output line).

  • def generators run in the threadpool exactly like in the plain StreamingResponse case.

Each item occupies exactly one line, so a literal newline inside a string is written as the JSON escape \n; you never have to think about it.

Consuming JSON Lines

On the command line, -N turns off curl’s output buffering so lines appear as they arrive:

curl -N http://127.0.0.1:8000/books/stream

From Python, read the response line by line and validate each line with the same model:

with c.stream("GET", "/books/stream") as response:
    for line in response.iter_lines():
        book = Book.model_validate_json(line)
        print(book.id, book.title)
1 Dune
2 Emma
3 Ubik

Browsers have no built-in JSON Lines client; read response.body from fetch as a stream and split on newlines, or use SSE if you control both sides.

The OpenAPI Effect

With a declared item type the operation documents a stream of items, using the itemSchema keyword, not an array schema:

{
  "description": "Successful Response",
  "content": {
    "application/jsonl": {
      "itemSchema": {
        "$ref": "#/components/schemas/Book"
      }
    }
  }
}

itemSchema is how OpenAPI describes sequential media types; generated clients that understand it can type each line, while tools that do not simply ignore it. See OpenAPI & Interactive Docs for how the schema reaches the interactive documentation.

An Invalid Item in the Middle

Validation happens lazily, item by item. If the third item cannot be validated, the first two have already been sent. In the test client the error surfaces as ResponseValidationError:

@app.get("/books/stream-broken")
async def stream_broken() -> AsyncIterable[Book]:
    yield BOOKS[0]
    yield {"id": "not-a-number", "title": "Bad"}
    yield BOOKS[2]


try:
    c.get("/books/stream-broken")
except Exception as exc:
    print("raised", type(exc).__name__, str(exc)[:60])
raised ResponseValidationError 1 validation error:
  {'type': 'int_parsing', 'loc': ('response', 'id')

Against a real server the status is already 200, so the client sees a truncated stream, not a 422; the next sections explain why and how to design for it.

Server-Sent Events (0.135.0)

Server-Sent Events (SSE) is a WHATWG standard for pushing text events from a server to a browser over one long-lived HTTP response. Browsers implement the client as EventSource, which handles parsing, automatic reconnection and resuming. FastAPI 0.135.0 (2026-03-01) added fastapi.sse with two names, EventSourceResponse and ServerSentEvent. Before it, applications used a third-party package or wrote the wire format by hand.

The Wire Format

An event is a block of field: value lines ended by a blank line. Lines that start with a colon are comments.

Field Meaning

data

The payload. Several data lines in one event are joined with newlines by the client.

event

The event type. The browser dispatches it to listeners registered with addEventListener(type, …​); without it the generic message event fires.

id

Sets the client’s last event ID, sent back as the Last-Event-ID header when it reconnects.

retry

Reconnection delay in milliseconds that the client should use after a lost connection.

: comment

Ignored by EventSource; used for keep-alive pings.

The Simplest Stream

Set response_class=EventSourceResponse (media type text/event-stream) on a path operation that yield`s. Each plain object — a Pydantic model, a dict, a list — becomes the `data: field, encoded as JSON. Declare the item type in the return annotation to have items validated, documented and serialized in Rust; a def generator runs in the threadpool.

from collections.abc import AsyncIterable, Iterable

from fastapi import FastAPI
from fastapi.sse import EventSourceResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel


class Review(BaseModel):
    id: int
    book_id: int
    stars: int
    text: str


REVIEWS = [Review(id=i, book_id=1, stars=3 + i % 3, text=f"Review {i}") for i in range(1, 4)]

app = FastAPI()


@app.get("/reviews/stream", response_class=EventSourceResponse)
async def stream_reviews() -> AsyncIterable[Review]:
    for review in REVIEWS:
        yield review


@app.get("/reviews/stream-sync", response_class=EventSourceResponse)
def stream_reviews_sync() -> Iterable[Review]:
    yield from REVIEWS


c = TestClient(app)
r = c.get("/reviews/stream")
print(r.status_code, r.headers["content-type"])
print("cache-control:", r.headers["cache-control"], "| x-accel-buffering:", r.headers["x-accel-buffering"])
print(r.text)
print(c.get("/reviews/stream-sync").text == r.text)

Output:

200 text/event-stream; charset=utf-8
cache-control: no-cache | x-accel-buffering: no
data: {"id":1,"book_id":1,"stars":4,"text":"Review 1"}

data: {"id":2,"book_id":1,"stars":5,"text":"Review 2"}

data: {"id":3,"book_id":1,"stars":3,"text":"Review 3"}


True

FastAPI applies several SSE best practices without being asked: it sets Cache-Control: no-cache, sets X-Accel-Buffering: no so Nginx does not buffer the stream, and sends a comment line (: ping) when the generator has been idle for 15 seconds, so proxies and load balancers do not close an apparently dead connection. The same generator rules as before apply; the return annotation is optional (without it the objects are JSON-encoded without validation).

ServerSentEvent: Controlling the Fields

To set event, id, retry or a comment, yield ServerSentEvent objects instead of plain data. All fields are optional.

Table 2. ServerSentEvent fields
Field Type Behaviour

data

any JSON-serializable value

Always encoded as JSON, including strings: "hello" is sent as data: "hello" with quotes. Mutually exclusive with raw_data.

raw_data

str

Sent as-is, with no JSON encoding; multi-line strings become several data: lines. Use it for pre-formatted text, HTML fragments, log lines or sentinels such as [DONE].

event

str

Event type; must be a single line.

id

str

Event id echoed back as Last-Event-ID; must be a single line and must not contain a null character.

retry

int, at least 0

Reconnect delay in milliseconds.

comment

str

Emitted as : comment lines; ignored by clients, useful as a hint to humans and proxies.

from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import ValidationError


@app.get("/reviews/events", response_class=EventSourceResponse)
async def stream_events() -> AsyncIterable[ServerSentEvent]:
    yield ServerSentEvent(comment="review feed")
    for review in REVIEWS:
        yield ServerSentEvent(data=review, event="review", id=str(review.id), retry=3000)
    yield ServerSentEvent(data="hello")
    yield ServerSentEvent(raw_data="plain text\nsecond line", event="note")
    yield ServerSentEvent(event="heartbeat")


print(c.get("/reviews/events").text)

for kwargs in ({"data": 1, "raw_data": "x"}, {"event": "a\nb"}, {"id": "a\0b"}, {"retry": -1}):
    try:
        ServerSentEvent(**kwargs)
    except ValidationError as exc:
        print(kwargs, "->", exc.errors()[0]["msg"])

Output (the first review is shown in full; the others follow the same shape):

: review feed

event: review
data: {"id":1,"book_id":1,"stars":4,"text":"Review 1"}
id: 1
retry: 3000

...

data: "hello"

event: note
data: plain text
data: second line

event: heartbeat

{'data': 1, 'raw_data': 'x'} -> Value error, Cannot set both 'data' and 'raw_data' on the same ServerSentEvent. Use 'data' for JSON-serialized payloads or 'raw_data' for pre-formatted strings.
{'event': 'a\nb'} -> Value error, SSE 'event' must be a single line
{'id': 'a\x00b'} -> Value error, SSE 'id' must not contain null characters
{'retry': -1} -> Input should be greater than or equal to 0

Because ServerSentEvent is a Pydantic model, field problems are reported when you construct it: the invalid combinations above raise ValidationError inside your generator, which ends the stream (see Errors in the Middle of a Stream). The quotes in data: "hello" surprise people who expect a bare string; if the client does not run JSON.parse on event.data, send the string with raw_data instead. When you yield a ServerSentEvent, its data is serialized without the item-type validation that plain objects get, so the annotation AsyncIterable[ServerSentEvent] describes the envelope, not the payload.

Resuming with Last-Event-ID

When the connection drops, EventSource waits for the retry interval (a browser default if you never sent one) and opens a new request. If the last event it processed carried an id, the new request has a Last-Event-ID header with that value. The server decides what "resume" means: replay everything after the id, send only a snapshot, or ignore it. Declare the header like any other (see Headers & Cookies); the underscore name last_event_id maps to Last-Event-ID.

from typing import Annotated

from fastapi import FastAPI, Header
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.sse import EventSourceResponse, ServerSentEvent
from fastapi.testclient import TestClient

app = FastAPI()
app.add_middleware(GZipMiddleware, minimum_size=10)

REVIEWS = [{"id": i, "text": f"Review {i}"} for i in range(1, 6)]


@app.get("/books/{book_id}/reviews/stream", response_class=EventSourceResponse)
async def stream_reviews(
    book_id: int,
    last_event_id: Annotated[int | None, Header()] = None,
) -> AsyncIterable[ServerSentEvent]:
    start = last_event_id or 0
    for review in REVIEWS:
        if review["id"] <= start:
            continue
        yield ServerSentEvent(data=review, event="review", id=str(review["id"]))


c = TestClient(app)
r = c.get("/books/1/reviews/stream", headers={"Accept-Encoding": "gzip"})
print(r.headers.get("content-encoding"), [l for l in r.text.splitlines() if l.startswith("id:")])
r = c.get("/books/1/reviews/stream", headers={"Last-Event-ID": "3"})
print([l for l in r.text.splitlines() if l.startswith("id:")])
r = c.get("/books/1/reviews/stream", headers={"Last-Event-ID": "abc"})
print(r.status_code, r.json()["detail"][0]["msg"], r.json()["detail"][0]["loc"])

Output:

None ['id: 1', 'id: 2', 'id: 3', 'id: 4', 'id: 5']
['id: 4', 'id: 5']
422 Input should be a valid integer, unable to parse string as an integer ['header', 'last-event-id']

Three lessons are in that output:

  • The reconnect with Last-Event-ID: 3 receives only events 4 and 5.

  • The browser echoes whatever string you put in id:. If your ids are not integers (UUIDs, cursors), declare the header as str; an int annotation turns the first non-numeric id into a 422 on every reconnect, and the client can never resume.

  • GZipMiddleware left the event stream uncompressed (content-encoding is None), because Starlette’s default exclusion list contains text/event-stream; compression would buffer events until a block fills.

Resuming only works if the server can find the missed events. A durable log, a database table keyed by id, a Redis stream or a bounded in-memory replay buffer (below) are the usual sources; ids must be ordered and stable.

A Live Review Feed: Fan-Out, Replay and Cleanup

The Bookshelf API pushes new reviews to every connected reader. The ReviewFeed class keeps a bounded replay buffer and one queue per subscriber; the finally block in subscribe is where a departing client is forgotten.

import asyncio
from collections import deque
from collections.abc import AsyncIterable
from typing import Annotated

from fastapi import FastAPI, Header
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel


class ReviewIn(BaseModel):
    stars: int
    text: str


class ReviewOut(ReviewIn):
    id: int
    book_id: int


class ReviewFeed:
    """In-memory fan-out: a bounded replay buffer plus one queue per subscriber."""

    def __init__(self, history: int = 100) -> None:
        self.history: deque[ReviewOut] = deque(maxlen=history)
        self.subscribers: set[asyncio.Queue[ReviewOut]] = set()
        self.next_id = 1

    def publish(self, book_id: int, review: ReviewIn) -> ReviewOut:
        item = ReviewOut(id=self.next_id, book_id=book_id, **review.model_dump())
        self.next_id += 1
        self.history.append(item)
        for queue in self.subscribers:
            queue.put_nowait(item)
        return item

    async def subscribe(self, after_id: int | None) -> AsyncIterable[ReviewOut]:
        queue: asyncio.Queue[ReviewOut] = asyncio.Queue(maxsize=100)
        replay = [r for r in self.history if after_id is not None and r.id > after_id]
        self.subscribers.add(queue)  # register before replaying so nothing is missed
        try:
            for item in replay:
                yield item
            while True:
                yield await queue.get()
        finally:
            self.subscribers.discard(queue)


feed = ReviewFeed()
app = FastAPI()


@app.post("/books/{book_id}/reviews", status_code=201)
async def add_review(book_id: int, review: ReviewIn) -> ReviewOut:
    return feed.publish(book_id, review)


@app.get("/reviews/stream", response_class=EventSourceResponse)
async def review_stream(
    last_event_id: Annotated[int | None, Header()] = None,
) -> AsyncIterable[ServerSentEvent]:
    async for review in feed.subscribe(last_event_id):
        yield ServerSentEvent(data=review, event="review", id=str(review.id), retry=2000)

The test below runs the app under Uvicorn inside the same event loop and talks to it with httpx: one review is posted while nobody listens, a reader connects, a live review arrives, the reader goes away, another review is posted during the "outage", and the reader returns with Last-Event-ID.

import json

import httpx
import uvicorn


async def main() -> None:
    server = uvicorn.Server(uvicorn.Config(app, port=18766, log_level="warning"))
    serving = asyncio.create_task(server.serve())
    while not server.started:
        await asyncio.sleep(0.01)
    async with httpx.AsyncClient(base_url="http://127.0.0.1:18766") as client:
        await client.post("/books/1/reviews", json={"stars": 5, "text": "missed while offline"})

        async def listen(label: str, headers: dict[str, str], count: int) -> None:
            async with client.stream("GET", "/reviews/stream", headers=headers) as r:
                seen = 0
                async for line in r.aiter_lines():
                    if line.startswith("data:"):
                        print(label, json.loads(line[5:])["text"])
                        seen += 1
                    if seen == count:
                        return

        first = asyncio.create_task(listen("A", {}, 1))
        await asyncio.sleep(0.1)
        print("subscribers:", len(feed.subscribers))
        await client.post("/books/1/reviews", json={"stars": 4, "text": "live review"})
        await first
        await client.post("/books/2/reviews", json={"stars": 3, "text": "sent during the outage"})
        await asyncio.wait_for(listen("A reconnected", {"Last-Event-ID": "2"}, 1), 3)
        await asyncio.sleep(0.1)
        print("subscribers after disconnects:", len(feed.subscribers))
    server.should_exit = True
    await serving


asyncio.run(main())
subscribers: 1
A live review
A reconnected sent during the outage
subscribers after disconnects: 0

The reader never saw the review posted before it connected (it did not send Last-Event-ID), received the live one, and after reconnecting received exactly the one it missed. The subscriber set is empty again afterwards, which is the property that prevents memory leaks. An in-process feed only reaches clients connected to this worker; with several workers or hosts, publish through a shared broker (Redis pub/sub or streams, a message queue) and let each worker subscribe to it.

An SSE Session End to End

The diagram shows one complete session: the initial request, events with ids, a keep-alive ping, a dropped connection, the automatic reconnect with Last-Event-ID, the replay of the missed event and the final client close.

sequenceDiagram autonumber participant B as Browser EventSource participant P as Reverse proxy participant A as FastAPI route participant F as Review feed B->>P: GET /reviews/stream, Accept text/event-stream P->>A: forward request (response buffering off) A->>F: subscribe, no Last-Event-ID A-->>B: 200 text/event-stream, Cache-Control no-cache, X-Accel-Buffering no F-->>A: review 41 A-->>B: event review, id 41, retry 2000 F-->>A: review 42 A-->>B: event review, id 42 Note over A,B: idle 15 s, comment ": ping" keeps proxy timeouts away A--xB: connection lost (network, proxy restart, deploy) A->>F: finally block, unsubscribe Note over B: waits retry ms (2000), then reconnects B->>P: GET /reviews/stream, Last-Event-ID 42 P->>A: forward request A->>F: subscribe, replay after id 42 F-->>A: review 43 (missed while offline) A-->>B: 200, event review, id 43 Note over B: user leaves the page, close() or tab closed B--xA: client disconnect A->>F: CancelledError, finally block, unsubscribe

Steps 9 and 10 are the part developers forget: on the server side every dropped connection is also a cleanup event. Steps 13 and 14 are why id values must be ordered and replayable.

SSE with POST (and Why It Matters)

On the server, SSE works with any HTTP method; EventSourceResponse is just a streaming response with a media type. That allows protocols that stream events as the answer to a POST with a body, for example a chat endpoint that takes a prompt and streams tokens back, or an MCP-style transport.

from fastapi import FastAPI
from fastapi.sse import EventSourceResponse, ServerSentEvent
from fastapi.testclient import TestClient
from pydantic import BaseModel


class Prompt(BaseModel):
    text: str


@app.post("/assistant/stream", response_class=EventSourceResponse)
async def stream_answer(prompt: Prompt) -> AsyncIterable[ServerSentEvent]:
    for word in prompt.text.split():
        yield ServerSentEvent(data=word, event="token")
    yield ServerSentEvent(raw_data="[DONE]", event="done")


r = TestClient(app).post("/assistant/stream", json={"text": "Dune is great"})
print(r.headers["content-type"])
print(r.text)
text/event-stream; charset=utf-8
event: token
data: "Dune"

event: token
data: "is"

event: token
data: "great"

event: done
data: [DONE]

The sentinel [DONE] is sent with raw_data because it is not valid JSON. The client side is the catch: the browser EventSource API can only issue GET requests, cannot set request headers (no Authorization) and cannot send a body. A page that needs POST or a bearer token reads the response of fetch as a stream (or uses a small library that implements the SSE parser on top of fetch), and then has to do its own reconnection and Last-Event-ID handling. Server-to-server clients such as httpx have no such limits: client.stream("POST", url, json=…​) and aiter_lines() do the job.

The Browser Side: EventSource

The following server ends each response after two events, forcing the client to reconnect; it uses the Last-Event-ID logic from above and retry=200 so the demo is quick.

from collections.abc import AsyncIterable
from typing import Annotated

from fastapi import FastAPI, Header
from fastapi.sse import EventSourceResponse, ServerSentEvent

app = FastAPI()
REVIEWS = [f"Review {i}" for i in range(1, 6)]


@app.get("/reviews/stream", response_class=EventSourceResponse)
async def stream(last_event_id: Annotated[int | None, Header()] = None) -> AsyncIterable[ServerSentEvent]:
    start = last_event_id or 0
    # Send at most two events per connection, then end the stream: the client reconnects.
    for n, text in enumerate(REVIEWS[start:start + 2], start=start + 1):
        yield ServerSentEvent(data={"id": n, "text": text}, event="review", id=str(n), retry=200)

The client is a few lines of standard EventSource code. It was executed with Node.js 24, which ships the same API behind the --experimental-eventsource flag; browsers have it enabled by default. (The setInterval line is only needed in Node.js, which would otherwise exit while the reconnect timer is pending.)

const source = new EventSource("http://127.0.0.1:8000/reviews/stream");
let opens = 0;
source.onopen = () => console.log(`open #${++opens}`);
source.addEventListener("review", (e) => {
  console.log(`review id=${e.lastEventId} data=${e.data}`);
  if (e.lastEventId === "5") { source.close(); console.log("closed"); clearInterval(keepAlive); }
});
source.onerror = () => console.log("error: connection lost, browser will retry");
const keepAlive = setInterval(() => {}, 1000); // Node exits while the reconnect timer is pending
open #1
review id=1 data={"id": 1, "text": "Review 1"}
review id=2 data={"id": 2, "text": "Review 2"}
error: connection lost, browser will retry
open #2
review id=3 data={"id": 3, "text": "Review 3"}
review id=4 data={"id": 4, "text": "Review 4"}
error: connection lost, browser will retry
open #3
review id=5 data={"id": 5, "text": "Review 5"}
closed

The same stream seen by curl, resuming after event 2, shows the headers FastAPI adds:

curl -sN -i -H "Last-Event-ID: 2" http://127.0.0.1:8000/reviews/stream
HTTP/1.1 200 OK
date: Fri, 02 Oct 2026 08:41:31 GMT
server: uvicorn
content-type: text/event-stream; charset=utf-8
cache-control: no-cache
x-accel-buffering: no
transfer-encoding: chunked

event: review
data: {"id": 3, "text": "Review 3"}
id: 3
retry: 200

event: review
data: {"id": 4, "text": "Review 4"}
id: 4
retry: 200

Client behaviour worth knowing, all from the WHATWG specification:

  • EventSource reconnects after a network error or after the server ends the stream normally; call close() to stop. That is why a server that simply returns from its generator makes the browser come back immediately — for a finished stream, send a final event your client reacts to by calling close(), or answer the next request with HTTP 204, which tells the client to stop reconnecting.

  • A response whose status is not 200 or whose media type is not text/event-stream fails the connection without retrying; a 401 therefore ends the session for good.

  • Cross-origin requests need CORS headers on the response, plus withCredentials if cookies are involved; see Middleware, CORS & Proxies.

  • Over HTTP/1.1 browsers allow only about six connections per host, and every open EventSource takes one. HTTP/2 multiplexes streams over a single connection and removes the practical limit.

  • Because EventSource cannot send an Authorization header, protect the stream with a session cookie (see Security Fundamentals) or, if you must use a URL token, a short-lived single-purpose one — URLs end up in logs.

Disconnect Handling

Clients leave without warning: a closed tab, a phone entering a tunnel, a proxy restart. A long-lived stream must notice and release what it holds — queues, database cursors, subscriptions, upstream connections.

  • Starlette watches for the disconnect while the response is streaming. When the server announces ASGI spec 2.3 or older — which Uvicorn 0.54 does — Starlette listens for http.disconnect in a task next to the sender and cancels the sending task; when the server announces spec 2.4 or later, a failed send raises OSError, which Starlette turns into ClientDisconnect.

  • In an async def generator the visible effect is that asyncio.CancelledError is raised at the generator’s current await (or the generator is closed). Put cleanup in finally, and re-raise CancelledError if you catch it.

  • FastAPI’s SSE implementation produces the events from a separate task and cancels it when the response finishes, so cleanup in your generator runs the same way.

  • There is no disconnect detection while the generator is busy running synchronous code: cancellation can only be delivered at an await. A CPU-bound loop in an async def generator never gets cancelled; see Async & Concurrency.

The generator below produces a tick every 50 ms forever. The test starts a real Uvicorn server, reads three events with httpx and then leaves the stream block, which closes the connection:

import asyncio
from collections.abc import AsyncIterable

from fastapi import FastAPI
from fastapi.sse import EventSourceResponse, ServerSentEvent

app = FastAPI()
log: list[str] = []


@app.get("/clock", response_class=EventSourceResponse)
async def clock() -> AsyncIterable[ServerSentEvent]:
    log.append("stream opened")
    try:
        n = 0
        while True:
            n += 1
            yield ServerSentEvent(data={"tick": n}, id=str(n))
            await asyncio.sleep(0.05)
    except asyncio.CancelledError:
        log.append("cancelled (client went away)")
        raise
    finally:
        log.append("cleanup done")


async def main() -> None:
    import httpx
    import uvicorn

    server = uvicorn.Server(uvicorn.Config(app, port=18765, log_level="warning"))
    task = asyncio.create_task(server.serve())
    while not server.started:
        await asyncio.sleep(0.01)
    async with httpx.AsyncClient(base_url="http://127.0.0.1:18765") as client:
        async with client.stream("GET", "/clock") as response:
            async for line in response.aiter_lines():
                print("client got:", line)
                if line.startswith("id: 3"):
                    break  # leaving the block closes the connection
    await asyncio.sleep(0.3)
    print(log)
    server.should_exit = True
    await task


asyncio.run(main())
client got: data: {"tick": 1}
client got: id: 1
client got:
client got: data: {"tick": 2}
client got: id: 2
client got:
client got: data: {"tick": 3}
client got: id: 3
['stream opened', 'cancelled (client went away)', 'cleanup done']

Without the finally, the feed example would keep a dead queue in subscribers and publish would fill it forever. Further guidance:

  • Bound everything. Use asyncio.Queue(maxsize=…​) and decide what to do when a slow client lets it fill (drop the oldest event, drop the client, or send a "resync" event); an unbounded queue turns a slow reader into a memory leak.

  • Do not hold a database session across waits. See A Dependency’s Session Inside a Stream.

  • Idle timeouts are yours too. A silent connection can be closed by any hop; FastAPI’s 15-second : ping covers proxies for SSE, but plain StreamingResponse and JSON Lines streams have no automatic ping.

  • Restarts. A running stream delays Uvicorn’s graceful shutdown; --timeout-graceful-shutdown (Uvicorn setting) bounds the wait, after which clients are cut off and, if they are EventSource, reconnect to the new process.

The Keep-Alive Ping

When the generator produces nothing for 15 seconds, FastAPI sends : ping (an SSE comment ignored by clients). The interval is an internal constant, not a public setting; the test below patches the private fastapi.routing._PING_INTERVAL only to avoid waiting, and is a demonstration, not a recommended practice.

import asyncio
from collections.abc import AsyncIterable

from fastapi import FastAPI, routing
from fastapi.sse import EventSourceResponse, ServerSentEvent
from fastapi.testclient import TestClient

routing._PING_INTERVAL = 0.05  # private; patched here only to avoid waiting 15 s

app = FastAPI()


@app.get("/slow", response_class=EventSourceResponse)
async def slow() -> AsyncIterable[ServerSentEvent]:
    yield ServerSentEvent(data="first")
    await asyncio.sleep(0.18)
    yield ServerSentEvent(data="second")


print(TestClient(app).get("/slow").text)
data: "first"

: ping

: ping

: ping

data: "second"

If a proxy in front of you has a shorter idle timeout than 15 seconds, yield your own ServerSentEvent(comment="keep-alive") more often.

Errors in the Middle of a Stream

The status code and headers are sent with the first chunk. After that:

  • An HTTPException raised inside the generator cannot produce a 404 or 500 response any more.

  • An unhandled exception ends the response abnormally: the server closes the connection without the terminating chunk, and clients report an incomplete read.

  • Everything that can be decided before the first chunk should be decided before the generator starts: do those checks in a dependency (dependencies run before the stream) or in an ordinary function that returns the generator.

import asyncio
from collections.abc import AsyncIterable
from typing import Annotated

import httpx
import uvicorn
from fastapi import Depends, FastAPI, HTTPException
from fastapi.sse import EventSourceResponse, ServerSentEvent

BOOKS = {1: "Dune"}
app = FastAPI()


def get_book(book_id: int) -> str:
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail="Book not found")
    return BOOKS[book_id]


BookDep = Annotated[str, Depends(get_book)]


@app.get("/checked/{book_id}/stream", response_class=EventSourceResponse)
async def checked(book: BookDep) -> AsyncIterable[ServerSentEvent]:
    yield ServerSentEvent(data={"title": book})


@app.get("/late/{book_id}/stream", response_class=EventSourceResponse)
async def late(book_id: int) -> AsyncIterable[ServerSentEvent]:
    if book_id not in BOOKS:
        raise HTTPException(status_code=404, detail="Book not found")  # runs inside the stream
    yield ServerSentEvent(data={"title": BOOKS[book_id]})


@app.get("/failing", response_class=EventSourceResponse)
async def failing() -> AsyncIterable[ServerSentEvent]:
    yield ServerSentEvent(data="first")
    raise RuntimeError("database went away")


async def main() -> None:
    server = uvicorn.Server(uvicorn.Config(app, port=18768, log_level="critical"))
    serving = asyncio.create_task(server.serve())
    while not server.started:
        await asyncio.sleep(0.01)
    async with httpx.AsyncClient(base_url="http://127.0.0.1:18768") as c:
        for path in ("/checked/2/stream", "/late/2/stream", "/failing"):
            try:
                async with c.stream("GET", path) as r:
                    body = [line async for line in r.aiter_lines() if line]
                    print(path, r.status_code, body)
            except httpx.RemoteProtocolError as exc:
                print(path, "status was", r.status_code, "then:", type(exc).__name__)
    server.should_exit = True
    await serving


asyncio.run(main())
/checked/2/stream 404 ['{"detail":"Book not found"}']
/late/2/stream status was 200 then: RemoteProtocolError
/failing status was 200 then: RemoteProtocolError

The dependency version returns a real 404; the same check inside the generator produces a 200 followed by a broken stream, and so does an exception after the first event. In practice, catch expected failures inside the generator and turn them into an application-level event the client understands (event: error with a message), then end the stream cleanly; log the unexpected ones. An EventSource that sees a broken stream will reconnect, so a permanently failing stream becomes a retry loop unless the server stops answering it with 200.

Proxies, Buffering and Compression

Streaming only works if every hop passes bytes on immediately. A single buffering component turns a live feed into a stream that arrives in bursts or only at the end.

Table 3. Where streams get stuck
Component Symptom Fix

Nginx as reverse proxy

Events arrive in batches or when the response ends

FastAPI sets X-Accel-Buffering: no on SSE responses, which Nginx honours. For JSON Lines and other streams set proxy_buffering off; on the location, or add the header yourself on the response. Also raise proxy_read_timeout above your longest idle period and keep proxy_http_version 1.1; with an empty Connection header.

CDN or cache

The stream is cached or held back

Send Cache-Control: no-cache (SSE responses already do); exclude the path from caching and from response buffering in the CDN settings.

Compression middleware

Events are held until a compression block fills

Starlette’s gzip middleware skips text/event-stream by default; for other streams exclude the path or the media type, or do not compress at all.

Load balancer idle timeout

The connection dies after, for example, 60 seconds of silence

Send application keep-alives more often than the timeout (FastAPI’s SSE ping is 15 seconds).

Worker or server timeouts

Requests are killed at a fixed duration

Make sure the ASGI server and any worker manager do not enforce a maximum request time on streaming routes; see Deployment.

HTTP/1.1 connection limits

The sixth stream from one browser hangs

Serve the site over HTTP/2.

location /reviews/stream {
    proxy_pass http://bookshelf_upstream;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;       # redundant for SSE (the X-Accel-Buffering header does it), needed for JSON Lines
    proxy_read_timeout 1h;     # longer than the longest silence between events
}

The Nginx block above is configuration, not Python, and was not executed in this environment. For how proxies forward the original scheme and host (and --forwarded-allow-ips), see Middleware, CORS & Proxies.

A Dependency’s Session Inside a Stream

A dependency with yield wraps the request: its code before yield runs before the path operation, and the code after yield runs after the response has been sent. For a normal response that moment comes right after the handler; for a stream, it comes when the stream ends. That has been the rule since FastAPI 0.118.0 (2025-09-29), which reverted the earlier behaviour (0.106.0 to 0.117.x) where the exit code ran as soon as the path operation function returned, before the body was sent and therefore before a streamed body had been produced — a database session used inside the generator was already closed. Details of the lifecycle are on the Dependency Injection page and in the official "Dependencies with yield" guides.

The example records the order of events. FakeSession stands in for a SQLModel or SQLAlchemy session and fails if used after it was closed:

import asyncio
from collections.abc import AsyncIterable, Iterator
from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.sse import EventSourceResponse, ServerSentEvent
from fastapi.testclient import TestClient

events: list[str] = []


class FakeSession:
    def __init__(self) -> None:
        self.closed = False

    def reviews(self) -> list[str]:
        if self.closed:
            raise RuntimeError("session is closed")
        return ["r1", "r2", "r3"]


def get_session() -> Iterator[FakeSession]:
    session = FakeSession()
    events.append("session opened")
    try:
        yield session
    finally:
        session.closed = True
        events.append("session closed")


SessionDep = Annotated[FakeSession, Depends(get_session)]
EarlySessionDep = Annotated[FakeSession, Depends(get_session, scope="function")]

app = FastAPI()


@app.get("/reviews/stream", response_class=EventSourceResponse)
async def stream(session: SessionDep) -> AsyncIterable[ServerSentEvent]:
    for row in session.reviews():
        events.append(f"yield {row}")
        yield ServerSentEvent(data=row)
    events.append("generator finished")


@app.get("/reviews/stream-early", response_class=EventSourceResponse)
async def stream_early(session: EarlySessionDep) -> AsyncIterable[ServerSentEvent]:
    events.append("generator started")
    await asyncio.sleep(0.05)  # simulate waiting for the next review
    for row in session.reviews():
        yield ServerSentEvent(data=row)


c = TestClient(app)
c.get("/reviews/stream")
print(events)
events.clear()
try:
    c.get("/reviews/stream-early")
except ExceptionGroup as group:
    print("ExceptionGroup ->", repr(group.exceptions[0]))
print(events)

Output:

['session opened', 'yield r1', 'yield r2', 'yield r3', 'generator finished', 'session closed']
ExceptionGroup -> RuntimeError('session is closed')
['session opened', 'generator started', 'session closed']

With the default scope ("request") the session stays open until the generator has finished, then closes: this is the behaviour you want when the stream reads from the session. With scope="function" (0.121.0 and later) the exit code runs when the path operation function returns, which for a generator happens before streaming starts: the session is closed while the generator is still waiting, and the next use fails (the error arrives wrapped in an ExceptionGroup). scope="function" is the right tool for the opposite case, a dependency used only to authorize the request, so that its session is not held for the whole duration of a long stream.

That points at the real design problem: with the default scope, a database session is held for as long as the stream lives — possibly hours for an SSE feed — which exhausts connection pools. For long streams, do not carry a session across waits. Use a dependency that returns a session factory (or the engine) and open a short-lived session per query inside the generator:

import asyncio
from collections.abc import AsyncIterable, Iterator
from contextlib import contextmanager
from typing import Annotated

from fastapi import Depends, FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient

app = FastAPI()
opened: list[str] = []


class Db:
    @contextmanager
    def session(self) -> Iterator[list[str]]:
        opened.append("open")
        try:
            yield ["r1", "r2"]
        finally:
            opened.append("close")


def get_db() -> Db:
    return Db()


@app.get("/reviews/poll", response_class=StreamingResponse)
async def poll(db: Annotated[Db, Depends(get_db)]) -> AsyncIterable[str]:
    for _ in range(2):
        with db.session() as rows:  # short-lived session per poll, never held across waits
            yield ",".join(rows) + "\n"
        await asyncio.sleep(0.01)


c = TestClient(app)
print(c.get("/reviews/poll").text.splitlines(), opened)
['r1,r2', 'r1,r2'] ['open', 'close', 'open', 'close']

Two cautions. In this simplified example the yield happens inside the with block, so the session is open while the chunk is handed to the client; yield after leaving the block if the consumer may be slow. And a blocking database driver inside an async def generator blocks the event loop; use def generators (threadpool) or an async driver, see SQL Databases with SQLModel.

The OpenAPI Effect

  • A path operation that yields documents its 200 response by media type: application/jsonl or text/event-stream with an itemSchema instead of a body schema.

  • For JSON Lines the itemSchema is the schema of your item type. For SSE with a plain item type, the same applies.

  • For SSE with ServerSentEvent, FastAPI emits the canonical event schema (data, event, id, retry):

{
  "description": "Successful Response",
  "content": {
    "text/event-stream": {
      "itemSchema": {
        "type": "object",
        "properties": {
          "data": {"type": "string"},
          "event": {"type": "string"},
          "id": {"type": "string"},
          "retry": {"type": "integer", "minimum": 0}
        }
      }
    }
  }
}
  • The Last-Event-ID header parameter appears as an optional header parameter named last-event-id with the type you declared.

  • Plain StreamingResponse operations show no item schema (only the response description and, when your subclass sets one, the media type).

  • The generated document still declares "openapi": "3.1.0", which was observed in the example above, even though itemSchema comes from a newer revision of the OpenAPI specification; clients that do not know the keyword ignore it.

SSE, WebSockets or Polling?

Table 4. Choosing a push mechanism
Polling Server-Sent Events WebSockets

Direction

Client asks repeatedly

Server to client only

Both directions

Protocol

Plain HTTP requests

One long HTTP response

HTTP upgrade to a framed protocol

Latency

Up to the polling interval

Immediate

Immediate

Reconnect and resume

Not needed (stateless)

Built into EventSource, resume with Last-Event-ID

You write it

Browser API

fetch

EventSource (GET only, no custom headers)

WebSocket (no custom headers either)

Server cost per client

Low per request, high in total

One open connection

One open connection

Proxies and firewalls

Always fine

Fine if buffering is off

Must allow the upgrade

Authentication

Any header or cookie

Cookies (or a short-lived URL token) in browsers

Cookies, subprotocol or a first message

Binary data

Yes

No (text only; base64 if you must)

Yes

FastAPI

Normal path operations, ETag or since parameters

EventSourceResponse

@app.websocket

Choose it for

Rare updates, simplest thing that works

Notifications, live feeds, progress, AI token streams

Chat, collaboration, games, anything chatty in both directions

For the Bookshelf API: the review feed is SSE (one direction, resumable, and built-in browser support); the per-book chat is a WebSocket (messages in both directions); a nightly export is a StreamingResponse; and a dashboard that refreshes every minute can poll with a conditional request.

Testing Streams

  • TestClient runs the app in the same process and returns the finished response; it is suitable for every finite stream in this page. Use client.stream("GET", url) with iter_lines() to process lines like a real client.

  • Infinite streams (the clock, the live feed) cannot be consumed to completion in TestClient. Test the pieces: the ReviewFeed class directly, the generator with a finite input, and the connection behaviour with a real Uvicorn server as in the examples above.

  • Do not assert on timing of individual chunks with TestClient; it does not reproduce chunk boundaries.

  • Starlette 1.7 reports a deprecation warning when TestClient is used with httpx; see Testing for the current recommendation.

Where Older Guides Are Outdated, and What Changed Recently

  • Streaming by building the response by hand. Before 0.134.0 (2026-02-27) a path operation could not be a generator; you wrote a generator function and returned StreamingResponse(gen()). That still works, but yield plus response_class is shorter, and the JSON Lines form validates every item.

  • Reading a whole file inside the generator. Streaming examples that call file.read() once and yield the result stream nothing and hold the whole file in memory. Read in fixed-size chunks (as iter_file does), and close the file in a with block inside the generator.

  • Third-party SSE packages. Guides that only mention packages such as sse-starlette predate the built-in SSE support in 0.135.0 (2026-03-01). EventSourceResponse in fastapi.sse covers the common needs (events with fields, Last-Event-ID, keep-alive, buffering headers). The third-party package remains a valid choice if you rely on its own extras; check its documentation for current options.

  • Dependency exit code and streams. 0.106.0 (2023-12-25) made yield-dependency exit code run before the response was sent, which broke sessions used by streaming bodies; 0.118.0 (2025-09-29) moved it after the response again. Older advice such as "do not use a yield dependency with StreamingResponse`" no longer applies. `scope="function" (0.121.0, 2025-11-03) is the explicit opt-in to the early exit.

  • JSON serialization speed. From 0.130.0 (2026-02-22) responses with a return type or response model are serialized by Pydantic’s Rust core; the JSON Lines and SSE paths use it for typed items, so a declared item type is also the fast path. ORJSONResponse and UJSONResponse were deprecated in 0.131.0 (2026-02-22) and are not needed for streams.

  • Request-time sessions. Many tutorials open a database session in a dependency and use it for the whole life of a stream; with the 0.118.0 semantics that holds a connection for the entire stream. Use short-lived sessions.

Table 5. Version history of the streaming features
FastAPI Date Change

0.106.0

2023-12-25

Yield dependency exit code can raise HTTPException after yield; resources from yield dependencies no longer usable in background tasks.

0.118.0

2025-09-29

Exit code of yield dependencies runs after the response is sent (reverted earlier behaviour).

0.121.0

2025-11-03

Depends(scope="request") and Depends(scope="function").

0.134.0

2026-02-27

Streaming JSON Lines and binary data by using yield in path operations.

0.135.0

2026-03-01

Server-Sent Events: fastapi.sse.EventSourceResponse and ServerSentEvent.

Pitfalls

  • Forgetting response_class=EventSourceResponse makes the same generator stream JSON Lines, not events, and a browser EventSource refuses it (wrong media type). Forgetting StreamingResponse on a bytes generator does the same for raw data.

  • A bare response_class=StreamingResponse sends no Content-Type; subclass it and set media_type.

  • A blocking call inside an async def generator freezes every request on that worker; use a def generator or non-blocking APIs. Cancellation (disconnect) is also only delivered at an await.

  • Putting resource cleanup after the loop instead of in finally skips it on disconnect and on errors.

  • An unbounded per-client queue is a memory leak waiting for a slow client.

  • Last-Event-ID declared as int while ids are strings returns 422 forever; declare the type that matches the ids.

  • Strings in data are JSON-encoded and quoted; use raw_data for plain text.

  • data and raw_data cannot be combined; event and id must be single lines; retry cannot be negative.

  • A status code or HTTPException after the first chunk cannot change the response; validate before streaming.

  • Any buffering hop (proxy, CDN, compression) turns a live stream into bursts; test through the real proxy.

  • EventSource cannot send headers or a body, and reconnects forever on a normal end of stream; plan authentication, a terminal event and a 204 for the "do not come back" case.

  • Holding a database session for the lifetime of a stream exhausts the pool; see A Dependency’s Session Inside a Stream.

  • An in-memory fan-out only reaches clients of the same process; with several workers, use a shared broker.

  • TestClient cannot exercise infinite streams or chunk timing; test the pieces and use a real server for connection behaviour.

  • Do not rely on fastapi.routing._PING_INTERVAL; it is private and used here only to demonstrate the ping.