Streaming LLM Responses in FastAPI: SSE, Backpressure, and the Latency Traps Nobody Warns You About
Stream LLM tokens in FastAPI with SSE the moment they arrive, kill buffering layers, and cancel on disconnect so you stop paying for unseen tokens.
Contents
Short answer: Use FastAPI's StreamingResponse with an async generator that yields tokens the instant the provider emits them, format each chunk as an SSE event, and strip out every buffering layer (gzip, proxy buffering) on that route. The latency you add is almost never FastAPI itself — it's a buffering proxy, a blocked event loop, or a client disconnect you never cancelled. Get those three right and your end-to-end TTFT tracks the provider's.
The working SSE pattern
Stream end-to-end so the first token reaches the user the moment it exists (Latitude). The generator should do nothing between receiving a provider chunk and yielding it — no accumulation, no post-processing that waits for the full response.
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.get("/chat")
async def chat(request: Request, q: str):
async def gen():
stream = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": q}],
stream=True,
)
async for chunk in stream:
if await request.is_disconnected():
await stream.close() # stop paying for tokens nobody sees
break
delta = chunk.choices[0].delta.content
if delta:
yield f"data: {delta}\n\n" # SSE frame
yield "data: [DONE]\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache",
"X-Accel-Buffering": "no"}, # disable nginx buffering
)The \n\n terminator per event is not optional — the browser's EventSource won't dispatch a message until it sees the blank line.
The gotchas from production
- Buffering proxies and gzip. This is the number one cause of "why does my stream arrive all at once?" A buffering proxy or gzip layer commonly breaks streaming and inflates perceived TTFT (practitioner-known). nginx buffers proxied responses by default; set
X-Accel-Buffering: noorproxy_buffering off. Never gzip atext/event-streamroute — compression buffers to fill a window before flushing. - Blocking the event loop. FastAPI only stalls if you stall it. Any sync call inside the generator — a blocking SDK,
time.sleep, a heavy tokenizer, a sync DB write — freezes the whole loop and every concurrent stream with it. Keep the generator fully async; push CPU-bound work torun_in_threadpool. - Client disconnects. A user closing the tab does not automatically stop your upstream request. Poll
request.is_disconnected()between chunks, or catchasyncio.CancelledError.
Propagating cancellation so you stop paying
This is the one that shows up on your bill. When a client disconnects mid-stream, cancelling the upstream request stops paying for tokens the user will never see. Without it, the model keeps generating to completion and you're charged for every output token into the void.
- Check
await request.is_disconnected()inside the loop andawait stream.close()on the provider handle. - Wrap the provider call so
CancelledError(raised when the ASGI server drops the connection) closes the upstream stream in afinallyblock. - On long generations under bursty traffic, this is real money — a 2,000-token answer abandoned at token 50 is ~1,950 tokens you shouldn't pay for.
Measuring real TTFT end-to-end
The trap: teams measure TTFT at the provider SDK and call it done. That number is a lie if a proxy buffers 40 tokens before flushing. Measure where it matters — at the client.
- Client-side: timestamp the request start and the first
onmessageevent in the browser. That's the TTFT your users actually feel. - Server-side: log the delta between generator start and first
yield, and separately between first yield and the ASGIhttp.response.body. - If provider TTFT is 300ms but browser TTFT is 1.2s, your latency is in the transport — go hunt the buffering layer, not the model.
Streaming latency is a transport problem, not a model problem — fix buffering, cancellation, and measurement before you touch the prompt.
Related: more writing.
Frequently asked
- SSE or WebSockets for LLM streaming?
- SSE is simpler and enough for one-way token streaming; use WebSockets only if you need bidirectional traffic like interrupting mid-generation from the client.
- Why does my stream arrive all at once?
- A proxy or gzip layer is buffering. Disable buffering on the route (X-Accel-Buffering: no or proxy_buffering off) and never gzip a text/event-stream response.
- How do I stop billing when a user leaves?
- Detect the disconnect with request.is_disconnected() and call close() on the provider stream so token generation halts instead of running to completion.
- Does FastAPI block during streaming?
- Only if you do sync work in the generator. Any blocking call freezes the event loop and every concurrent stream; keep it async and offload CPU work to a threadpool.
Keep reading
Why RAG fails in production — and where
When RAG breaks, the model usually isn't the problem — retrieval is, about 73% of the time. Here's the real failure map and what to fix first.
ReadWhy I don't use LangChain in production RAG
Frameworks are great for a demo. In production, the abstraction you can't see into costs more than it saves.
ReadWhy Dense Retrieval Fails on Acronyms, IDs, and Code (and How Hybrid Fixes It)
Vector search misses exact terms like SKUs and error codes because embeddings blur rare tokens; a sparse channel restores exact-match signal.
ReadGet new essays by email
Occasional field notes on production RAG and LLM systems. No spam, unsubscribe anytime.