Real-time: WebSockets and server-sent events
Push updates to users the moment they happen. Build live order tracking in FastAPI with both WebSockets and server-sent events, and learn how to choose between them, secure them and scale them.
Normally the browser asks and the server answers. But some things happen on the server while the user is looking at the screen: a barista marks your coffee ready, a new message arrives in a chat, a cricket score changes. Polling the server every few seconds ("is it ready yet?") wastes requests and still feels slow. Real-time techniques let the server push updates the moment they happen.
What you'll build: live order tracking for Brew & Bean. A customer's page shows the status of their order and updates instantly when staff change it. You will build it twice, with WebSockets and with server-sent events (SSE), and learn when to choose which.
Prerequisites: Python 3.10+, basic FastAPI and basic JavaScript in the browser. Install: pip install fastapi uvicorn websockets.
Four ways to get live data
Real-time options
| Technique | How it works | Good for | Downside |
|---|---|---|---|
| Short polling | Client asks every few seconds | Simple, rare updates | Wasteful, delayed, extra load |
| Long polling | Server holds the request open until there is news | Older systems, fallback | More complex, still one request per update |
| Server-sent events (SSE) | One long HTTP response; server streams events down | One-way updates: status, notifications, live feeds | Server to client only; text only |
| WebSocket | One persistent two-way connection | Chat, games, collaborative editing | Needs more care to scale; not plain HTTP afterwards |
The key question is direction. If only the server needs to talk (order status, notifications, progress bars), SSE is simpler. If both sides talk often and quickly (chat, multiplayer, live cursors), use WebSockets. For Brew & Bean, order tracking is one-way, so SSE is enough, but you will see both.
The server
Setup
- 1Create a folder
realtime-demo, make and activate a virtual environment. - 2Install:
pip install fastapi uvicorn websockets. Thewebsocketspackage lets uvicorn speak the WebSocket protocol. - 3Save the code below as
main.py. - 4Run:
uvicorn main:app --port 8000.
import asyncio
import json
from collections import defaultdict
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI(title="Brew & Bean live order status")
# Demo only: lets index.html opened from a file talk to this API (needed for SSE).
# In a real app, list your website's exact origin instead of "*".
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# Pretend database: three orders, all just received
orders = {i: {"id": i, "status": "received"} for i in (1, 2, 3)}
class StatusUpdate(BaseModel):
status: str
# ---------- WebSocket: who is connected, per order ----------
ws_clients: dict[int, set[WebSocket]] = defaultdict(set)
# ---------- SSE: one queue per connected browser, per order ----------
sse_queues: dict[int, set[asyncio.Queue]] = defaultdict(set)
async def publish(order_id: int, message: dict) -> None:
"""Send a message to everyone watching this order, over both channels."""
for ws in list(ws_clients[order_id]):
try:
await ws.send_json(message)
except Exception:
ws_clients[order_id].discard(ws) # connection died
for queue in list(sse_queues[order_id]):
queue.put_nowait(message)
@app.patch("/orders/{order_id}/status")
async def update_status(order_id: int, body: StatusUpdate):
order = orders.get(order_id)
if order is None:
raise HTTPException(status_code=404, detail="No such order")
order["status"] = body.status
await publish(order_id, {"order_id": order_id, "status": body.status})
return order
@app.websocket("/ws/orders/{order_id}")
async def order_socket(websocket: WebSocket, order_id: int):
if order_id not in orders:
await websocket.close(code=1008) # policy violation: unknown order
return
await websocket.accept()
ws_clients[order_id].add(websocket)
try:
# Send the current state straight away, then keep the connection open
await websocket.send_json({"order_id": order_id, "status": orders[order_id]["status"]})
while True:
await websocket.receive_text() # the client may send "ping"; we just wait
except WebSocketDisconnect:
pass
finally:
ws_clients[order_id].discard(websocket)
@app.get("/orders/{order_id}/events")
async def order_events(order_id: int, request: Request):
if order_id not in orders:
raise HTTPException(status_code=404, detail="No such order")
queue: asyncio.Queue = asyncio.Queue()
sse_queues[order_id].add(queue)
async def stream():
try:
current = {"order_id": order_id, "status": orders[order_id]["status"]}
yield f"data: {json.dumps(current)}\n\n"
while True:
if await request.is_disconnected():
break
try:
message = await asyncio.wait_for(queue.get(), timeout=15)
except asyncio.TimeoutError:
yield ": keep-alive\n\n" # a comment line stops proxies closing an idle stream
continue
yield f"data: {json.dumps(message)}\n\n"
finally:
sse_queues[order_id].discard(queue)
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)publish is the heart of it: when an order changes, it pushes a message to every connected WebSocket and every SSE queue for that order.
How the code works
State. orders is a stand-in for the database. ws_clients and sse_queues remember who is watching which order.
Changing a status. PATCH /orders/{id}/status updates the order and calls publish. This is the only place that creates events, so every watcher sees the same updates.
WebSocket route. The browser connects to ws://.../ws/orders/1. The server accepts, sends the current status, then loops on receive_text(). That loop does not do much, but it keeps the connection open and raises WebSocketDisconnect when the user leaves, so the finally block can remove them. Without this cleanup the server would keep sending to dead connections.
SSE route. SSE is plain HTTP that never finishes. The response has the media type text/event-stream and the server writes lines in the format data: <text> followed by a blank line. Each browser gets its own asyncio.Queue; publish drops messages in, and the stream sends them out. If nothing happens for 15 seconds it sends a comment line (starting with :) as a keep-alive, because proxies and load balancers often close silent connections.
Test it from the terminal
# Terminal A: stay connected and listen (-N turns off curl's buffering)
curl -N http://127.0.0.1:8000/orders/2/events
# Terminal B: change the order
curl -X PATCH http://127.0.0.1:8000/orders/2/status -H "Content-Type: application/json" -d '{"status":"preparing"}'Use curl.exe on Windows PowerShell. Terminal A stays open and prints an event as soon as terminal B runs.
data: {"order_id": 2, "status": "received"}
data: {"order_id": 2, "status": "preparing"}The browser
Save this as index.html next to main.py. Open the file directly in your browser. Then change order 1 with the PATCH command (...orders/1/status) and watch both lines change at once. The browser classes used here, WebSocket and EventSource, are built into every modern browser, so no library is needed.
<!DOCTYPE html>
<html>
<body>
<h2>Order 1</h2>
<p>WebSocket: <b id="ws">connecting...</b></p>
<p>SSE: <b id="sse">connecting...</b></p>
<script>
// WebSocket version
const socket = new WebSocket("ws://127.0.0.1:8000/ws/orders/1");
socket.onmessage = (event) => {
document.getElementById("ws").textContent = JSON.parse(event.data).status;
};
socket.onclose = () => {
document.getElementById("ws").textContent += " (disconnected)";
};
// Server-sent events version
const source = new EventSource("http://127.0.0.1:8000/orders/1/events");
source.onmessage = (event) => {
document.getElementById("sse").textContent = JSON.parse(event.data).status;
};
</script>
</body>
</html>EventSource reconnects automatically if the connection drops. A WebSocket does not, so real apps add reconnect code.
curl -X PATCH http://127.0.0.1:8000/orders/1/status -H "Content-Type: application/json" -d '{"status":"ready"}'Run it while index.html is open. Both status labels change at once, with no refresh.
Opening index.html
If the page stays on "connecting...", check that the server is running on port 8000. The CORSMiddleware line in main.py is there only for this demo: the browser treats a page opened from a file as a different origin, and EventSource needs the server's permission. In a real app, list your website's exact origin instead of "*". WebSocket connections are not covered by CORS, so also check the Origin header yourself.
What changes in production
Real-time in production
| Concern | What to do |
|---|---|
| Authentication | Browsers cannot set an Authorization header on WebSocket or EventSource. Use a cookie, or send a short-lived token in the first message or query string, and verify it before accepting. Check that this user owns this order |
| Several server copies | A message published on server A does not reach a user connected to server B. Use a shared broker such as Redis pub/sub so every server hears every event (see *Caching with Redis* for Redis) |
| HTTPS | Use wss:// (secure WebSocket) with HTTPS pages, and https:// for SSE |
| Idle timeouts | Proxies close silent connections. Send keep-alives (comment lines in SSE, ping messages in WebSocket) |
| Reconnection | Clients should reconnect and fetch the current state, because events sent while disconnected are lost |
| Limits | Each open connection uses memory. Cap connections per user and size of messages |
| Hosting | Some serverless platforms do not keep long connections; check your host's WebSocket and streaming support |
Sockets need security too
A connected WebSocket is open for a long time, so treat it like any endpoint: authenticate it, limit message size, validate every incoming message, and never trust the client's claim about who it is. Anyone can open ws://your-api/ws/orders/1 unless you check access.
Choosing between them
| If you need... | Choose |
|---|---|
| Server pushes order status, notifications, progress | SSE |
| Chat, multiplayer games, shared whiteboard | WebSocket |
| Streaming an AI answer token by token | SSE (the usual method for LLM streaming APIs) |
| Updates every minute or less often | Plain polling is fine |
| Mobile app push when the app is closed | Push notifications (FCM), not sockets |
How this comes up in interviews
Common questions: "WebSockets vs SSE vs polling?", "How would you build a live chat or a notification system?", "How do you scale WebSockets across many servers?" (a message broker such as Redis pub/sub or Kafka behind the servers, and sticky or any load balancing with shared state), and "What happens when the connection drops?" (client reconnects with backoff and refetches current state).
I am building [describe your app]. I need users to see [describe live update, e.g. new comments, delivery location, quiz scores] without refreshing. Compare polling, SSE and WebSocket for it. Recommend one, and describe the message format, how to authenticate the connection, what happens on reconnect, and how it works when I run three server instances.What to expect: A strong answer chooses based on direction and frequency, mentions a shared broker for multiple servers, reconnect handling and authentication.
Try it: Add a kitchen display
Add a WebSocket endpoint /ws/kitchen that receives every status change for all orders. In publish, also send the message to the kitchen connections. Test it with a browser page that appends each message to a list.
Try it: Add an event name and an id to SSE
SSE supports event: and id: lines, for example id: 7, event: status, data: {...}, followed by a blank line. Change the stream to send these, and in the browser listen with source.addEventListener("status", ...). Why is an id useful?
Key takeaways
- Real-time means the server pushes updates instead of the client polling.
- SSE is one-way, plain HTTP and auto-reconnecting: ideal for status updates, notifications and streamed text.
- WebSocket is a persistent two-way connection for chat, games and collaboration, but needs reconnect code.
- Clean up when connections close, and send keep-alives so proxies do not drop idle connections.
- Authenticate sockets, and use a shared broker such as Redis pub/sub when you run more than one server.
Check yourself
Finishing the quiz marks the lesson as done.
1. Customers only need to watch their order status change. Which technique is the simplest good fit?
2. Which feature is best served by WebSockets rather than SSE?
3. Your API runs on three servers. A staff member updates an order on server A, but the customer is connected to server B. What is needed?
4. Why does the SSE route send a comment line (: keep-alive) every 15 seconds?
5. Why must you handle WebSocketDisconnect and remove the client from your set?
6. Browsers cannot set custom headers on WebSocket and EventSource. How can you still authenticate?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.