Search Topics

Search across all FastAPI topics

GitHub

WebSockets

Real-Time

You built a chat app. Two users connected. Messages went nowhere. Let's fix that.

You build a chat app with WebSockets. Two users connect. User A sends a message. User B never receives it. You check the code — each WebSocket connection is independent. There's no built-in broadcast.

terminal
# Your WebSocket endpoint:
@app.websocket("/ws")
async def chat(websocket: WebSocket):
    await websocket.accept()
    while True:
        data = await websocket.receive_text()
        await websocket.send_text(f"You said: {data}")
        # ← Only echoes back to the SAME user!
        # User B never receives User A's messages.

# User A sends: "Hello everyone!"
# User A sees: "You said: Hello everyone!"
# User B sees: ... nothing.

Question

Why doesn't User B get the message? Because WebSocket connections are completely isolated. Each client talks to the server through its own private tunnel. If you want messages to reach other clients, you need to build that yourself.

WebSocket vs HTTP: Two Different Worlds

HTTP is like sending letters — you write one, send it, get a reply, done. WebSockets are like a phone call — you connect once and both sides can talk whenever they want.

HTTP (request-response):

Client sends request

GET /messages

Server responds

Here's your data

Connection closes

Done. Start over for next request.

WebSocket (persistent connection):

HTTP upgrade

Switch to WebSocket

Connection open

Both sides can talk anytime

Messages flow

Client ↔ Server

Until close

Either side can end it

websocket basics

What you just learned

HTTP connections are one-shot: request, response, close

WebSocket connections stay open — either side can send messages at any time

Each WebSocket connection is independent — there's no built-in way to talk between connections

A Basic WebSocket: Echo Server

Let's start with the simplest possible WebSocket — it accepts a connection, listens for messages, and echoes them back. This is exactly the "broken" chat from the hook, but it's a perfect starting point.

main.py
from fastapi import FastAPI, WebSocket

app = FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()  # Step 1: accept the connection
    while True:
        # Step 2: wait for a message
        data = await ws.receive_text()
        # Step 3: send something back
        await ws.send_text(f"You said: {data}")
    # This loops forever until the client disconnects

When Users Disappear

Clients disconnect all the time — they close the tab, lose WiFi, or their battery dies. Without handling this, your server throws an unhandled exception.

main.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            data = await ws.receive_text()
            await ws.send_text(f"Echo: {data}")
    except WebSocketDisconnect:
        # Client left — clean up gracefully
        print("Client disconnected")

Watch out

Always wrap your WebSocket loop in try/except. Without it, every disconnection becomes an unhandled exception in your logs. Noisy and misleading.

websocket lifecycle

What you just learned

Always call await ws.accept() before sending or receiving

WebSocket endpoints loop forever — they keep the connection alive

Wrap the loop in try/except WebSocketDisconnect to handle client disconnections cleanly

The Fix: Broadcasting with a ConnectionManager

Remember the original problem? Messages only went back to the sender. To build a real chat, you need to track all active connections and broadcast messages to everyone. That's what a ConnectionManager does.

main.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

class ConnectionManager:
    def __init__(self):
        self.active_connections: list[WebSocket] = []

    async def connect(self, ws: WebSocket):
        await ws.accept()
        self.active_connections.append(ws)

    def disconnect(self, ws: WebSocket):
        self.active_connections.remove(ws)

    async def broadcast(self, message: str):
        # Send to EVERY connected client
        for connection in self.active_connections:
            await connection.send_text(message)

manager = ConnectionManager()

@app.websocket("/ws/chat")
async def chat(ws: WebSocket):
    await manager.connect(ws)
    try:
        while True:
            data = await ws.receive_text()
            # Now EVERYONE gets the message!
            await manager.broadcast(f"User says: {data}")
    except WebSocketDisconnect:
        manager.disconnect(ws)
        await manager.broadcast("A user has left the chat")

broadcasting

What you just learned

A ConnectionManager tracks all active WebSocket connections in a list

broadcast() iterates through every connection and sends the message to each one

When a client disconnects, remove them from the list to avoid sending to dead connections

See It In Action

Three clients connect to the server. External events — webhooks, scheduled jobs, system alerts — arrive at the server, which broadcasts them to all connected clients in real-time. Watch what happens when a client disconnects mid-stream.

Sending Structured Data

Real apps don't just send text strings. Use receive_json() and send_json() for structured data.

main.py
@app.websocket("/ws/updates")
async def live_updates(ws: WebSocket):
    await ws.accept()
    try:
        while True:
            # Receive structured data from client
            data = await ws.receive_json()
            # data = {"action": "subscribe", "channel": "prices"}

            # Send structured response back
            await ws.send_json({
                "channel": data["channel"],
                "price": 42.50,
                "timestamp": "2024-01-15T10:30:00Z",
            })
    except WebSocketDisconnect:
        print("Client disconnected")

Go Deeper: Heartbeats & Dead Connections

WebSocket connections can die silently. The client loses WiFi, the browser tab crashes, a proxy times out. Without heartbeats, your server holds onto dead connections forever, leaking memory.

Uvicorn handles protocol-level pings automatically. You just configure the interval:

terminal
# Uvicorn sends automatic keepalive pings:
uvicorn main:app \
    --ws-ping-interval 20 \    # Ping every 20 seconds
    --ws-ping-timeout 20        # Close if no pong within 20 seconds

# The client's browser handles Pong responses automatically
# — you don't need any client-side code for this.

Ping / Pong in Action

Watch Uvicorn send periodic pings to keep the connection alive. When the client stops responding, the server detects the dead connection and cleans it up.

Ping / Pong Heartbeat

how Uvicorn detects dead connections

Uvicornping every 20s
PING →
← PONG
Client
alive
Heartbeats0
Statuswaiting
Interval20s
P
Ping
P
Pong
--ws-ping-interval 20 --ws-ping-timeout 20

Application-Level Heartbeats

Sometimes protocol-level pings aren't enough. You might need to detect stale sessions, refresh auth tokens, or measure latency. Here's how to add your own heartbeat logic:

main.py
import asyncio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect

app = FastAPI()

@app.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
    await ws.accept()

    async def send_heartbeats():
        """Send application-level heartbeat every 30s"""
        while True:
            await asyncio.sleep(30)
            try:
                await ws.send_json({"type": "heartbeat", "ts": time.time()})
            except Exception:
                break  # Connection is dead

    # Run heartbeat alongside the message loop
    heartbeat_task = asyncio.create_task(send_heartbeats())
    try:
        while True:
            data = await ws.receive_json()
            if data.get("type") == "pong":
                continue  # Client responded to our heartbeat
            await handle_message(data)
    except WebSocketDisconnect:
        heartbeat_task.cancel()
        print("Client disconnected")

Cleaning Up Stale Connections

With a ConnectionManager, you should periodically close connections that haven't responded to heartbeats.

main.py
import time

class ConnectionManager:
    def __init__(self):
        self.connections: dict[WebSocket, float] = {}  # ws → last_seen

    async def connect(self, ws: WebSocket):
        await ws.accept()
        self.connections[ws] = time.time()

    def disconnect(self, ws: WebSocket):
        self.connections.pop(ws, None)

    def mark_alive(self, ws: WebSocket):
        self.connections[ws] = time.time()

    async def cleanup_stale(self, max_age: float = 60):
        """Remove connections not seen in max_age seconds"""
        now = time.time()
        stale = [ws for ws, last in self.connections.items()
                 if now - last > max_age]
        for ws in stale:
            self.disconnect(ws)
            try:
                await ws.close(code=1001, reason="Heartbeat timeout")
            except Exception:
                pass  # Already dead

heartbeats & cleanup

What you just learned

Uvicorn handles protocol-level pings automatically with --ws-ping-interval

Application-level heartbeats let you detect stale sessions and measure latency

Track last_seen timestamps and periodically close connections that go silent

Think about it...

What happens to a WebSocket connection if the server restarts? Does the client automatically reconnect?

Hint: Think about what the WebSocket protocol defines vs what you have to build yourself.

Key Points

@app.websocket()

Declare WebSocket endpoints just like HTTP routes

Accept First

Always call await ws.accept() before sending or receiving

WebSocketDisconnect

Catch this exception to handle client disconnections cleanly

ConnectionManager

Track active connections for broadcasting to multiple clients

JSON Support

Use send_json() and receive_json() for structured data

Uvicorn --ws auto

Uvicorn supports WebSockets natively with configurable backends

Ping / Pong

Uvicorn sends automatic keepalive pings to detect dead connections

--ws-ping-interval

Control how often Uvicorn pings clients (default 20s, 0 to disable)

Stale Cleanup

Track last_seen timestamps and close connections that go silent