Backend Development
Hands-onIntermediate 40 min· Production-ready APIs · 14 of 20

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

TechniqueHow it worksGood forDownside
Short pollingClient asks every few secondsSimple, rare updatesWasteful, delayed, extra load
Long pollingServer holds the request open until there is newsOlder systems, fallbackMore complex, still one request per update
Server-sent events (SSE)One long HTTP response; server streams events downOne-way updates: status, notifications, live feedsServer to client only; text only
WebSocketOne persistent two-way connectionChat, games, collaborative editingNeeds 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

  1. 1Create a folder realtime-demo, make and activate a virtual environment.
  2. 2Install: pip install fastapi uvicorn websockets. The websockets package lets uvicorn speak the WebSocket protocol.
  3. 3Save the code below as main.py.
  4. 4Run: uvicorn main:app --port 8000.
main.py
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
# 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.

Terminal A (the second event appears the moment you run the PATCH)
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.

index.html
<!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.

Terminal
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

ConcernWhat to do
AuthenticationBrowsers 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 copiesA 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)
HTTPSUse wss:// (secure WebSocket) with HTTPS pages, and https:// for SSE
Idle timeoutsProxies close silent connections. Send keep-alives (comment lines in SSE, ping messages in WebSocket)
ReconnectionClients should reconnect and fetch the current state, because events sent while disconnected are lost
LimitsEach open connection uses memory. Cap connections per user and size of messages
HostingSome 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, progressSSE
Chat, multiplayer games, shared whiteboardWebSocket
Streaming an AI answer token by tokenSSE (the usual method for LLM streaming APIs)
Updates every minute or less oftenPlain polling is fine
Mobile app push when the app is closedPush 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).

Prompt · Design a real-time feature
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

  1. Real-time means the server pushes updates instead of the client polling.
  2. SSE is one-way, plain HTTP and auto-reconnecting: ideal for status updates, notifications and streamed text.
  3. WebSocket is a persistent two-way connection for chat, games and collaboration, but needs reconnect code.
  4. Clean up when connections close, and send keep-alives so proxies do not drop idle connections.
  5. 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.

Start free