Area 08 // BackendLesson 129 / 14014 min read
Streaming with SSE, and WebSockets
Server-sent events (SSE) keep one HTTP response open so the server can push tokens to the browser as the model writes them, which is how most chat apps stream answers, while WebSockets open a two-way connection for when the client also needs to send messages during the stream.
Think of it like this
Analogy
SSE is a live radio broadcast: once you tune in, the station keeps talking and you listen; you can't talk back on the same channel. A WebSocket is a phone call: both sides can speak at any time over the same open line. For an AI reading out its answer, radio is usually enough; for a conversation where you might interrupt, you want the phone.
The problem it solves
An LLM may take 10 seconds to write a full answer, but it produces the first words within a second. If your API waits for the whole response, users stare at a spinner. You need a way to push partial results to the browser as they're generated (streaming and latency), and sometimes to let the user send messages (like "stop") while the answer is still streaming.
How it works: streaming with SSE
- The client sends a request (often
POST /chatwith the question, orGET /stream?q=for simple cases). - The server responds with
Content-Type: text/event-streamand keeps the connection open. - Each event is text: optional
event: nameandid:lines, thendata: ...(often JSON), then a blank line. - The server forwards tokens from the provider's stream (
stream=Trueor the SDK's streaming helper) as events, then sends a finaldoneevent with metadata (citations, usage). - Browsers read it with
EventSource(GET only, auto-reconnect) orfetchwith a stream reader (works with POST and auth headers). - Operational details: disable proxy buffering, raise idle timeouts, send periodic comments (
: ping) to keep connections alive, and cancel the LLM call if the client disconnects. - Errors mid-stream: the status is already 200, so send an
event: errormessage the UI can show.
How it works: WebSockets
- The client opens a WebSocket (
ws://orwss://) with an HTTP upgrade request; the connection stays open. - Both sides send messages whenever they like: text or binary.
- Good fits: voice assistants (audio both ways), collaborative editors, agents that need user input mid-task, "stop generating" and other live controls, multiplayer apps.
- Costs: stateful connections are harder to load-balance and scale (sticky sessions or a shared pub/sub), need their own auth on connect, heartbeats and reconnection logic.
- In FastAPI:
@app.websocket("/ws"), thenawait ws.accept(),receive_text()andsend_json().
See it in code
- Install
pip install fastapi httpx- Needs
- Python 3.10+. The token list stands in for a provider's stream.
The same streamed answer over SSE and over a WebSocket.
import asyncio, json
from fastapi import FastAPI, WebSocket
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
app = FastAPI()
TOKENS = ["Annual", " leave", " is", " 25", " days", " [1]"]
# SSE: one HTTP response that stays open; each event is "data: ...\n\n".
@app.get("/stream")
async def stream(q: str):
async def events():
for t in TOKENS: # in real code: tokens from the LLM's stream
await asyncio.sleep(0.01)
yield f"data: {json.dumps({'token': t})}\n\n"
yield "event: done\ndata: {}\n\n"
return StreamingResponse(events(), media_type="text/event-stream")
# WebSocket: a two-way connection; the client can send while the server streams (e.g. "stop").
@app.websocket("/ws")
async def ws(socket: WebSocket):
await socket.accept()
question = await socket.receive_text()
for t in TOKENS:
await socket.send_json({"token": t})
await socket.send_json({"done": True, "question": question})
await socket.close()
client = TestClient(app)
with client.stream("GET", "/stream", params={"q": "leave?"}) as r:
print("SSE content-type:", r.headers["content-type"])
lines = [l for l in r.iter_lines() if l]
print("SSE lines:", lines[:2], "...", lines[-2:])
with client.websocket_connect("/ws") as s:
s.send_text("How much leave do I get?")
msgs = [s.receive_json() for _ in range(len(TOKENS) + 1)]
print("WebSocket answer:", "".join(m.get("token", "") for m in msgs), "|", msgs[-1])SSE content-type: text/event-stream; charset=utf-8
SSE lines: ['data: {"token": "Annual"}', 'data: {"token": " leave"}'] ... ['event: done', 'data: {}']
WebSocket answer: Annual leave is 25 days [1] | {'done': True, 'question': 'How much leave do I get?'}In a real endpoint the loop reads from the provider, e.g. async with client.messages.stream(...) as s: async for text in s.text_stream: yield f"data: {json.dumps({'token': text})}\n\n".
A real example
Example
The stream that arrived in one lump
A team's chat streamed nicely on their laptops, but in production answers appeared all at once after eight seconds. Their reverse proxy was buffering responses, and the load balancer cut idle connections at 30 seconds, killing long answers. They added X-Accel-Buffering: no and Cache-Control: no-cache headers, raised idle timeouts, sent a keep-alive comment every 15 seconds, and cancelled the provider stream when the client disconnected. Time to first visible token dropped from 8 s to 0.7 s, and abandoned answers stopped costing tokens.
The easy way
Start here
Use SSE for chat answers: a FastAPI StreamingResponse with text/event-stream, forwarding the SDK's stream, and fetch with a stream reader in the browser. Reach for WebSockets only when the client must send messages during the stream.
When you grow: the production setup
Production streaming includes structured event types (token, tool-call, citation, error, done), resumable streams with event IDs, keep-alives, proxy and load-balancer tuning, cancellation on disconnect, auth on connect for WebSockets, sticky sessions or pub/sub for scaling WebSockets, and metrics for TTFT and stream completion rates.
Trade-offs
What you gain
- Much better perceived speed.
- SSE is plain HTTP: simple, proxy-friendly, easy to debug.
- WebSockets enable real-time two-way experiences.
What it costs you
- Long-lived connections need timeout and proxy tuning.
- Errors after the first byte can't use status codes.
- WebSockets are harder to scale and secure.
When not to use it
- Short responses (classification, extraction) or background jobs: a normal JSON response is simpler.
Common mistakes
- Buffering proxies that hide the stream.
- Not cancelling the LLM call when the user leaves.
- Using WebSockets when SSE would do.
- No error event, so failed streams just stop.
- Forgetting auth on WebSocket connections.
Interview questions
Try answering out loud first, then open the model answer.
SSE vs WebSockets for an LLM chat app?
Model answer
SSE fits most chat apps: the client sends a request and the server streams the answer one way over plain HTTP, with simple infrastructure and automatic reconnection. WebSockets fit when both sides send messages during a session: voice, live interruptions, collaborative editing, agents asking for input. They cost more in scaling, auth and connection management.
How do you stream an LLM response through your backend?
Model answer
Call the provider with streaming enabled using an async client; in an async endpoint return a streaming response with text/event-stream that yields each token as an SSE data event, plus final events with citations and usage; set headers to disable buffering; send keep-alives; handle errors with an error event; and cancel the upstream stream if the client disconnects.
What breaks when streaming in production?
Model answer
Proxy or CDN buffering, idle timeouts on load balancers, missing keep-alives, connections dropped during deploys (drain them), lack of cancellation leading to wasted tokens, and mid-stream errors that the client can't see without explicit error events.
Quiz
Pick an answer to see whether it’s right and why.
0 of 5 answered
Key terms
- Server-sent events (SSE)
- A simple HTTP standard for a server to push a stream of text events to the browser over one long-lived response. Most LLM APIs stream tokens this way.
- WebSocket
- A long-lived, two-way connection between browser and server where either side can send messages at any time.
- Streaming (responses)
- Sending the model's reply token by token as it is generated, so the user starts reading within a second instead of waiting for the whole answer.
- Time to first token (TTFT)
- How long a user waits from sending a request until the first token of the reply arrives. Mostly set by queueing and prompt processing (prefill); it's the latency users feel most.
- Event stream (text/event-stream)
- The content type used by server-sent events, where each message is sent as data lines followed by a blank line.
Before and after
Progress is saved in this browser only.