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).
| Option | Wire format | Typical use | Notes |
|---|---|---|---|
|
Any bytes or text you produce |
File downloads, CSV exports, text from an LLM, video or audio |
You own encoding and |
JSON Lines |
|
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 |
|
Notifications and live feeds for browsers, AI chat, progress |
0.135.0. |
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)
Source: FastAPI: Stream Data
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
Source: FastAPI: Stream Data
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 |
|
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="")
Source: FastAPI: Stream JSON Lines
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 anasync def, or→ Iterable[Book]on adef: the item type is the response model. Every yielded item is validated, filtered (fields not inBookare 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).
-
defgenerators run in the threadpool exactly like in the plainStreamingResponsecase.
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)
Source: FastAPI: Stream JSON Lines
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])
Source: FastAPI: Stream JSON Lines
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 |
|---|---|
|
The payload. Several |
|
The event type. The browser dispatches it to listeners registered with |
|
Sets the client’s last event ID, sent back as the |
|
Reconnection delay in milliseconds that the client should use after a lost connection. |
|
Ignored by |
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.
| Field | Type | Behaviour |
|---|---|---|
|
any JSON-serializable value |
Always encoded as JSON, including strings: |
|
|
Sent as-is, with no JSON encoding; multi-line strings become several |
|
|
Event type; must be a single line. |
|
|
Event id echoed back as |
|
|
Reconnect delay in milliseconds. |
|
|
Emitted as |
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: 3receives 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 asstr; anintannotation turns the first non-numeric id into a422on every reconnect, and the client can never resume. -
GZipMiddlewareleft the event stream uncompressed (content-encodingisNone), because Starlette’s default exclusion list containstext/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.
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
Source: MDN: EventSource
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:
-
EventSourcereconnects after a network error or after the server ends the stream normally; callclose()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 callingclose(), 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-streamfails the connection without retrying; a401therefore ends the session for good. -
Cross-origin requests need CORS headers on the response, plus
withCredentialsif cookies are involved; see Middleware, CORS & Proxies. -
Over HTTP/1.1 browsers allow only about six connections per host, and every open
EventSourcetakes one. HTTP/2 multiplexes streams over a single connection and removes the practical limit. -
Because
EventSourcecannot send anAuthorizationheader, 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.disconnectin a task next to the sender and cancels the sending task; when the server announces spec 2.4 or later, a failedsendraisesOSError, which Starlette turns intoClientDisconnect. -
In an
async defgenerator the visible effect is thatasyncio.CancelledErroris raised at the generator’s currentawait(or the generator is closed). Put cleanup infinally, and re-raiseCancelledErrorif 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 anasync defgenerator 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
: pingcovers proxies for SSE, but plainStreamingResponseand 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 areEventSource, 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
HTTPExceptionraised 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.
| Component | Symptom | Fix |
|---|---|---|
Nginx as reverse proxy |
Events arrive in batches or when the response ends |
FastAPI sets |
CDN or cache |
The stream is cached or held back |
Send |
Compression middleware |
Events are held until a compression block fills |
Starlette’s gzip middleware skips |
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
}
Source: Nginx: proxy_buffering
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
200response by media type:application/jsonlortext/event-streamwith anitemSchemainstead of a bodyschema. -
For JSON Lines the
itemSchemais 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-IDheader parameter appears as an optional header parameter namedlast-event-idwith the type you declared. -
Plain
StreamingResponseoperations 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 thoughitemSchemacomes from a newer revision of the OpenAPI specification; clients that do not know the keyword ignore it.
SSE, WebSockets or Polling?
| 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 |
You write it |
Browser API |
|
|
|
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, |
|
|
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
-
TestClientruns the app in the same process and returns the finished response; it is suitable for every finite stream in this page. Useclient.stream("GET", url)withiter_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: theReviewFeedclass 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
TestClientis used withhttpx; 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, butyieldplusresponse_classis shorter, and the JSON Lines form validates every item. -
Reading a whole file inside the generator. Streaming examples that call
file.read()once andyieldthe result stream nothing and hold the whole file in memory. Read in fixed-size chunks (asiter_filedoes), and close the file in awithblock inside the generator. -
Third-party SSE packages. Guides that only mention packages such as
sse-starlettepredate the built-in SSE support in 0.135.0 (2026-03-01).EventSourceResponseinfastapi.ssecovers 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.
ORJSONResponseandUJSONResponsewere 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.
| FastAPI | Date | Change |
|---|---|---|
0.106.0 |
2023-12-25 |
Yield dependency exit code can raise |
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 |
|
0.134.0 |
2026-02-27 |
Streaming JSON Lines and binary data by using |
0.135.0 |
2026-03-01 |
Server-Sent Events: |
Pitfalls
-
Forgetting
response_class=EventSourceResponsemakes the same generator stream JSON Lines, not events, and a browserEventSourcerefuses it (wrong media type). ForgettingStreamingResponseon a bytes generator does the same for raw data. -
A bare
response_class=StreamingResponsesends noContent-Type; subclass it and setmedia_type. -
A blocking call inside an
async defgenerator freezes every request on that worker; use adefgenerator or non-blocking APIs. Cancellation (disconnect) is also only delivered at anawait. -
Putting resource cleanup after the loop instead of in
finallyskips it on disconnect and on errors. -
An unbounded per-client queue is a memory leak waiting for a slow client.
-
Last-Event-IDdeclared asintwhile ids are strings returns422forever; declare the type that matches the ids. -
Strings in
dataare JSON-encoded and quoted; useraw_datafor plain text. -
dataandraw_datacannot be combined;eventandidmust be single lines;retrycannot be negative. -
A status code or
HTTPExceptionafter 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.
-
EventSourcecannot send headers or a body, and reconnects forever on a normal end of stream; plan authentication, a terminal event and a204for 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.
-
TestClientcannot 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.