WebSockets

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13, Starlette 1.7 and Uvicorn 0.54, checked on 2026-10-02. All examples use Annotated where a parameter needs one, run on Python 3.10 or newer and were executed on Python 3.13: with TestClient where the behaviour can be observed in-process, and against a real Uvicorn server (with the websockets client library and Node.js) where the point is the live connection, such as handshake rejections, unhandled disconnects and shutdown. HTTP is one request, one response. A WebSocket starts as an ordinary HTTP request that asks the server to switch protocols; once the server agrees, the same TCP connection stays open and either side can send messages whenever it likes. That is what a chat room, a live dashboard, collaborative editing or a game needs, and what plain request/response cannot do. FastAPI does not implement the protocol: Uvicorn (or another ASGI server) does, Starlette provides the WebSocket object, and FastAPI adds what it adds everywhere else, parameter extraction, dependencies and validation, to the handshake. For one-way pushes read Streaming, JSON Lines & SSE first; the decision table is repeated in WebSockets, SSE or Polling?. The running scenario is the Bookshelf API from Introduction & Architecture: one chat room per book.

How a WebSocket Session Works

The protocol is RFC 6455. The client sends a GET with Upgrade: websocket and Sec-WebSocket-Key headers; the server answers 101 Switching Protocols; from then on both sides exchange frames (text, binary, ping, pong, close). In ASGI terms the server sends the application a websocket.connect event, the application replies with websocket.accept (or rejects), and after that websocket.receive, websocket.send, websocket.disconnect and websocket.close messages flow. Starlette’s WebSocket hides those messages behind methods such as accept(), receive_text(), send_json() and close().

Table 1. HTTP request/response compared with a WebSocket
HTTP request/response WebSocket

Initiative

Always the client

Either side, at any time

Lifetime

One exchange (connections may be reused)

One long-lived connection per client

Per-message overhead

Method, path, headers and cookies on every request

A frame header of 2 to 14 bytes

Where FastAPI looks for inputs

Path, query, header, cookie, body, form, files

Path, query, header, cookie and dependencies of the handshake request; messages arrive later and are read by your code

Errors

Status code and JSON body (HTTPException)

Close code and short reason (WebSocketException); before accept() the client sees only HTTP 403

Documented in OpenAPI

Yes

No (see OpenAPI and the Interactive Docs)

Typical use

CRUD, queries, commands

Chat, live dashboards, presence, collaborative editing, games

The whole Bookshelf chat in one picture, from the first connection to a user leaving. The next sections build each step.

sequenceDiagram autonumber participant A as Ana (browser) participant S as FastAPI app (route and ConnectionManager) participant B as Ben (browser) A->>S: GET /ws/books/7/chat with Upgrade websocket Note over S: dependencies run (ticket, Origin) S-->>A: 101 Switching Protocols (accept) S-->>A: event join ana B->>S: GET /ws/books/7/chat with Upgrade websocket S-->>B: 101 Switching Protocols (accept) S-->>A: broadcast join ben S-->>B: broadcast join ben B->>S: text frame {"text": "Loved the ending"} Note over S: validate with Pydantic S-->>A: broadcast message from ben S-->>B: broadcast message from ben B->>S: close frame 1000 (or the tab closes) Note over S: WebSocketDisconnect, finally removes the socket S-->>A: broadcast leave ben
Figure 1. One chat room session: connect, messages, broadcast, disconnect

Install a WebSocket Library

Uvicorn speaks the protocol through the websockets package or the wsproto package, and does not install either by default. pip install "fastapi[standard]" pulls in uvicorn[standard], whose extra includes websockets, so a project created by following Getting Started is already covered. With plain uvicorn or the bare fastapi package, add one explicitly:

pip install "uvicorn[standard]"   # or: pip install websockets

The failure is quiet, which is why it is worth knowing. Started with --ws none (the same situation as having no library), Uvicorn logs No supported WebSocket library detected and treats the upgrade request as an ordinary GET: the client sees a plain 404 instead of a connection (run: websockets.InvalidStatus: server rejected WebSocket connection: HTTP 404). --ws accepts auto (the default), none, websockets, websockets-sansio and wsproto. The websockets-sansio implementation was added in Uvicorn 0.35.0 (June 2025).

The First Endpoint: @app.websocket

A WebSocket route is declared with @app.websocket(path) (or @router.websocket(path) on an APIRouter, prefixes included). The function receives a WebSocket, which FastAPI re-exports from starlette.websockets (from fastapi import WebSocket). Two rules define every handler:

  • Call await websocket.accept() before sending or receiving. Until then the connection is a pending HTTP upgrade; calling close() instead rejects it.

  • Keep the function running. When it returns, FastAPI closes the connection; an async for or while True loop over the incoming messages is the normal shape.

from fastapi import FastAPI, WebSocket
from fastapi.testclient import TestClient

app = FastAPI()


@app.websocket("/ws/echo")
async def echo(websocket: WebSocket) -> None:
    await websocket.accept()
    async for text in websocket.iter_text():  # ends cleanly when the client disconnects
        await websocket.send_text(f"You said: {text}")
    print("client left")


client = TestClient(app)
with client.websocket_connect("/ws/echo") as ws:
    ws.send_text("hello")
    print(ws.receive_text())
    ws.send_text("again")
    print(ws.receive_text())

iter_text() ends quietly when the client disconnects, which is why the handler needs no try. The run printed:

You said: hello
You said: again
client left

websocket.application_state (what the server has done) and websocket.client_state (what the client has done) are WebSocketState values, importable from fastapi.websockets: CONNECTING, CONNECTED, DISCONNECTED and, for a rejection that carries an HTTP response, RESPONSE. They are handy for guarding a late send in cleanup code. The next handler also shows the other handshake data Starlette exposes: websocket.url, websocket.headers, websocket.query_params, websocket.path_params, websocket.cookies, websocket.client and websocket.scope (including the subprotocols the client offered). An APIRouter prefix applies to WebSocket routes like to any other:

from fastapi import APIRouter, FastAPI, WebSocket
from fastapi.testclient import TestClient

router = APIRouter(prefix="/ws/books", tags=["chat"])


@router.websocket("/{book_id}/state")
async def state(websocket: WebSocket, book_id: int) -> None:
    before = websocket.application_state
    await websocket.accept(subprotocol="bookshelf.v1" if "bookshelf.v1" in websocket.scope["subprotocols"] else None)
    await websocket.send_json({
        "before": before.name, "after": websocket.application_state.name,
        "client": websocket.client_state.name, "offered": websocket.scope["subprotocols"],
        "path_params": websocket.path_params, "url": websocket.url.path,
    })
    await websocket.close()


app = FastAPI()
app.include_router(router)
client = TestClient(app)
with client.websocket_connect("/ws/books/3/state", subprotocols=["bookshelf.v1", "other"]) as ws:
    print(ws.accepted_subprotocol)
    print(ws.receive_json())
print(sorted(client.get("/openapi.json").json()["paths"]))
bookshelf.v1
{'before': 'CONNECTING', 'after': 'CONNECTED', 'client': 'CONNECTED', 'offered': ['bookshelf.v1', 'other'], 'path_params': {'book_id': '3'}, 'url': '/ws/books/3/state'}
[]

The last line is the paths of /openapi.json: the route works, and it is not in the schema. The first line is the subprotocol the server chose in accept(subprotocol=…​); a client offers candidates with the Sec-WebSocket-Protocol header (subprotocols= in TestClient.websocket_connect) and the server must pick one of them or none. Subprotocols reappear in Authentication for WebSockets.

Text, Bytes and JSON Messages

A WebSocket message is a text frame (UTF-8) or a binary frame. Starlette offers three pairs of methods over those two frame types:

Table 2. The send and receive methods
Receive Send Notes

receive_text() / iter_text()

send_text(str)

Text frames. A binary frame raises an error here.

receive_bytes() / iter_bytes()

send_bytes(bytes)

Binary frames, such as a cover image or a protobuf message.

receive_json(mode="text") / iter_json()

send_json(data, mode="text")

JSON over text frames by default; pass mode="binary" to use binary frames. Not validated: you get plain Python data.

receive()

send(message)

The raw ASGI message. Use these, never the raw receive and send callables, so the connection state stays correct.

Every receive method raises WebSocketDisconnect when the client has gone; the iter_* variants end the loop instead.

from fastapi import FastAPI, WebSocket
from fastapi.testclient import TestClient

app = FastAPI()


@app.websocket("/ws/types")
async def types(websocket: WebSocket) -> None:
    await websocket.accept()
    text = await websocket.receive_text()
    await websocket.send_text(text.upper())
    blob = await websocket.receive_bytes()
    await websocket.send_bytes(blob[::-1])
    payload = await websocket.receive_json()
    await websocket.send_json({"title": payload["title"], "length": len(payload["title"])})
    # JSON over binary frames
    payload = await websocket.receive_json(mode="binary")
    await websocket.send_json({"binary": True, **payload}, mode="binary")
    # the raw ASGI message, whichever kind it is
    message = await websocket.receive()
    await websocket.send_json({"keys": sorted(k for k, v in message.items() if v is not None)})
    await websocket.close()


client = TestClient(app)
with client.websocket_connect("/ws/types") as ws:
    ws.send_text("dune")
    print(ws.receive_text())
    ws.send_bytes(b"\x01\x02\x03")
    print(ws.receive_bytes())
    ws.send_json({"title": "Dune"})
    print(ws.receive_json())
    ws.send_json({"a": 1}, mode="binary")
    print(ws.receive_json(mode="binary"))
    ws.send_text("raw")
    print(ws.receive_json())
DUNE
b'\x03\x02\x01'
{'title': 'Dune', 'length': 4}
{'binary': True, 'a': 1}
{'keys': ['text', 'type']}

The last line shows receive() returning the raw message: a dictionary with type (websocket.receive) and either text or bytes.

Validate Incoming JSON with Pydantic

FastAPI validates the handshake, never the messages: receive_json() returns whatever the client sent, and a text frame that is not JSON raises json.JSONDecodeError, an ordinary exception that is not a disconnect and ends your handler with a server error (run: json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)). Validate every message yourself with a Pydantic model, answer errors in-band, and keep the connection:

from fastapi import FastAPI, WebSocket, WebSocketDisconnect, status
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, ValidationError, TypeAdapter
from typing import Annotated, Literal


class ChatIn(BaseModel):
    kind: Literal["message"] = "message"
    text: Annotated[str, Field(min_length=1, max_length=500)]


class ChatOut(BaseModel):
    kind: Literal["message", "error"]
    text: str


app = FastAPI()


@app.websocket("/ws/validated")
async def validated(websocket: WebSocket) -> None:
    await websocket.accept()
    async for raw in websocket.iter_json():
        try:
            incoming = ChatIn.model_validate(raw)
        except ValidationError as exc:
            await websocket.send_json(
                ChatOut(kind="error", text=f"{exc.error_count()} validation error(s)").model_dump()
            )
            continue
        await websocket.send_json(ChatOut(kind="message", text=incoming.text).model_dump())


client = TestClient(app)
with client.websocket_connect("/ws/validated") as ws:
    ws.send_json({"text": "hi"})
    print(ws.receive_json())
    ws.send_json({"text": ""})
    print(ws.receive_json())
    ws.send_json({"nope": 1})
    print(ws.receive_json())
{'kind': 'message', 'text': 'hi'}
{'kind': 'error', 'text': '1 validation error(s)'}
{'kind': 'error', 'text': '1 validation error(s)'}

A discriminated union (kind: Literal[…​]) is the usual way to carry several message types on one socket; see Pydantic v2 Essentials. Two cautions: a Field(max_length=…​) check runs only after the whole frame has been received, so cap frame size at the server too (Limits: Idle Timeouts, Rate Limits and Message Size); and use the same message shape in both directions so clients need one parser.

Path, Query, Cookie, Header and Dependency Parameters

In a WebSocket handler FastAPI resolves parameters from the handshake request, with the same machinery as for HTTP operations: Path (and {name} path parameters), Query, Cookie, Header, Depends and Security. Body, Form and File make no sense (there is no request body) and are not available. Remember how a connection is made: browsers cannot attach custom headers, so the practical carriers are the path, the query string and cookies.

from typing import Annotated

from fastapi import (Cookie, Depends, FastAPI, Header, Query, WebSocket,
                     WebSocketDisconnect, WebSocketException, status)
from fastapi.testclient import TestClient

app = FastAPI()


async def get_username(
    websocket: WebSocket,
    session: Annotated[str | None, Cookie()] = None,
    token: Annotated[str | None, Query()] = None,
) -> str:
    if session is None and token is None:
        raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="credentials required")
    return f"reader-{(session or token)[:3]}"


Username = Annotated[str, Depends(get_username)]


@app.websocket("/ws/books/{book_id}")
async def book_socket(
    websocket: WebSocket,
    book_id: int,
    username: Username,
    last_seen: Annotated[int, Query(ge=0)] = 0,
    user_agent: Annotated[str | None, Header()] = None,
) -> None:
    await websocket.accept()
    await websocket.send_json(
        {"book_id": book_id, "who": username, "last_seen": last_seen, "agent": user_agent}
    )
    await websocket.close()


client = TestClient(app)


def attempt(url: str, **kw) -> None:
    try:
        with client.websocket_connect(url, **kw) as ws:
            print("OK  ", url, ws.receive_json())
            try:
                ws.receive_text()
            except WebSocketDisconnect as exc:
                print("     then closed", exc.code)
    except WebSocketDisconnect as exc:
        print("DENY", url, "code", exc.code, "reason", repr(exc.reason))


attempt("/ws/books/7?token=abc123&last_seen=4", headers={"user-agent": "demo-client"})
attempt("/ws/books/7")
attempt("/ws/books/not-a-number?token=abc123")
attempt("/ws/books/7?token=abc123&last_seen=-1")
client.cookies.set("session", "xyz789")
attempt("/ws/books/9")
OK   /ws/books/7?token=abc123&last_seen=4 {'book_id': 7, 'who': 'reader-abc', 'last_seen': 4, 'agent': 'demo-client'}
     then closed 1000
DENY /ws/books/7 code 1008 reason 'credentials required'
DENY /ws/books/not-a-number?token=abc123 code 1008 reason [{'type': 'int_parsing', 'loc': ['path', 'book_id'], 'msg': 'Input should be a valid integer, unable to parse string as an integer', 'input': 'not-a-number'}]
DENY /ws/books/7?token=abc123&last_seen=-1 code 1008 reason [{'type': 'greater_than_equal', 'loc': ['query', 'last_seen'], 'msg': 'Input should be greater than or equal to 0', 'input': '-1', 'ctx': {'ge': 0}}]
OK   /ws/books/9 {'book_id': 9, 'who': 'reader-xyz', 'last_seen': 0, 'agent': 'testclient'}
     then closed 1000

What the run shows:

  • A path parameter typed int and a Query(ge=0) constraint are enforced exactly as for HTTP.

  • get_username declares a websocket: WebSocket parameter. A dependency may ask for the WebSocket (or the more general HTTPConnection, injectable since 0.61.0) the same way a path function does, so one dependency can serve both HTTP and WebSocket routes if it only needs what both have.

  • A cookie set on the TestClient is sent with the handshake, so the cookie branch of the dependency works.

  • Failures during parameter resolution close the connection with code 1008 (policy violation) and the validation errors as the reason. The default handler is for WebSocketRequestValidationError, which, since 0.97.0, can be overridden like RequestValidationError:

from fastapi import FastAPI, WebSocket, WebSocketDisconnect, status
from fastapi.exceptions import WebSocketRequestValidationError
from fastapi.testclient import TestClient

app = FastAPI()


@app.exception_handler(WebSocketRequestValidationError)
async def ws_validation_handler(websocket: WebSocket, exc: WebSocketRequestValidationError):
    await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="invalid parameters")


@app.websocket("/ws/books/{book_id}")
async def book_socket(websocket: WebSocket, book_id: int) -> None:
    await websocket.accept()
    await websocket.send_json({"book_id": book_id})
    await websocket.close()


client = TestClient(app)
try:
    with client.websocket_connect("/ws/books/abc"):
        pass
except WebSocketDisconnect as exc:
    print(exc.code, repr(exc.reason))
1008 'invalid parameters'

Raise WebSocketException, Not HTTPException

There is no HTTP response to attach a status code to once the upgrade is in progress, so HTTPException is the wrong tool in a WebSocket dependency or handler. Raise WebSocketException(code=…​, reason=…​) (re-exported from Starlette since 0.87.0) with a close code from [close-codes]. It works in dependencies, as above, and in the handler body.

Dependencies can also be attached to a whole route or router (@app.websocket("/path", dependencies=[Depends(check)]), APIRouter(dependencies=[…​])), which is how the Origin check in Authentication for WebSockets is wired. Dependencies with yield also run, and their exit code runs when the connection ends, which is a long time for a socket; see Do Not Hold a Database Session for the Life of a Socket. Dependency basics, including this case, are on Dependency Injection.

Closing, Disconnects and Close Codes

A connection ends in one of four ways: the client closes it (or its tab, process or network goes away), your code calls close(), your code raises, or the server stops. What the other side sees is the part that surprises people, so the next run was made against a real Uvicorn server (uvicorn live_server:app) with the websockets client:

from fastapi import FastAPI, WebSocket, WebSocketException, status

app = FastAPI()


@app.websocket("/ws/deny-before-accept")
async def deny(websocket: WebSocket):
    await websocket.close(code=status.WS_1008_POLICY_VIOLATION)


@app.websocket("/ws/exception-after-accept")
async def exc_after(websocket: WebSocket):
    await websocket.accept()
    raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION, reason="nope")


@app.websocket("/ws/raw-loop")
async def raw_loop(websocket: WebSocket):
    await websocket.accept()
    while True:
        text = await websocket.receive_text()
        await websocket.send_text(text)


@app.websocket("/ws/books/{book_id}")
async def typed(websocket: WebSocket, book_id: int):
    await websocket.accept()
    await websocket.close()

@app.websocket("/ws/boom")
async def boom(websocket: WebSocket):
    await websocket.accept()
    raise RuntimeError("bug")
import asyncio, websockets

import os
BASE = "ws://127.0.0.1:" + os.environ.get("PORT", "18801")


async def main():
    for path in ["/ws/deny-before-accept", "/ws/books/abc", "/ws/nope"]:
        try:
            async with websockets.connect(BASE + path):
                print(path, "connected")
        except Exception as e:
            print(path, type(e).__name__, e)
    async with websockets.connect(BASE + "/ws/exception-after-accept") as ws:
        try:
            await ws.recv()
        except websockets.ConnectionClosed as e:
            print("after-accept exception:", ws.close_code, repr(ws.close_reason))
    async with websockets.connect(BASE + "/ws/boom") as ws:
        try:
            await ws.recv()
        except websockets.ConnectionClosed as e:
            print("boom:", ws.close_code, repr(ws.close_reason))
    async with websockets.connect(BASE + "/ws/raw-loop") as ws:
        await ws.send("hi"); print(await ws.recv())
    print("raw-loop client left")

asyncio.run(main())
/ws/deny-before-accept InvalidStatus server rejected WebSocket connection: HTTP 403
/ws/books/abc InvalidStatus server rejected WebSocket connection: HTTP 403
/ws/nope InvalidStatus server rejected WebSocket connection: HTTP 403
after-accept exception: 1008 'nope'
boom: 1006 ''
hi
raw-loop client left

On the server side, the same run logged the following for the same endpoints (access-log lines trimmed):

INFO:     127.0.0.1:60055 - "WebSocket /ws/deny-before-accept" 403
INFO:     connection rejected (403 Forbidden)
INFO:     127.0.0.1:60059 - "WebSocket /ws/boom" [accepted]
ERROR:    Exception in ASGI application
RuntimeError: bug
INFO:     127.0.0.1:60060 - "WebSocket /ws/raw-loop" [accepted]
ERROR:    Exception in ASGI application
starlette.websockets.WebSocketDisconnect: (1000, '')
  • Closing before accept() rejects the handshake with plain HTTP 403. The client never gets a close code, only HTTP 403, whatever code you passed (the TestClient shows the code and reason instead, one reason to also test against a real server). The same goes for a WebSocketException or a validation failure raised before accept(), which covers every dependency in the examples above. Starlette can send a different HTTP status with send_denial_response() when the server supports the denial-response extension.

  • WebSocketException after accept() becomes a proper close frame with that code and reason (1008 'nope').

  • An unhandled exception in your handler drops the connection without a close frame (the client reports 1006, an abnormal closure, which is never sent on the wire but assigned by the client library) and Uvicorn logs Exception in ASGI application.

  • A loop that does not handle WebSocketDisconnect (while True: await websocket.receive_text()) also logs an error every time a client leaves normally. Use iter_text() or iter_json(), or catch the exception.

  • Uvicorn’s own shutdown closes open sockets with 1012 (service restart) and logs the unhandled WebSocketDisconnect of loops that do not catch it (run: closed 1012 on the client after sending SIGINT to the server). Clients should treat 1012 as "reconnect".

Table 3. Close codes and when to use them
Code and constant Meaning Use in an application

1000 WS_1000_NORMAL_CLOSURE

Normal closure

The default for close(), and what a client that leaves politely sends.

1001 WS_1001_GOING_AWAY

Endpoint going away

Idle timeouts, server restarts, a page navigation.

1008 WS_1008_POLICY_VIOLATION

Policy violation

Authentication or authorization failure, rate limit exceeded, invalid parameters.

1009 WS_1009_MESSAGE_TOO_BIG

Message too big

A frame above your limit. Uvicorn closes with it when --ws-max-size is exceeded.

1011 WS_1011_INTERNAL_ERROR

Unexpected server error

Closing deliberately after catching a bug you cannot recover from.

1012 WS_1012_SERVICE_RESTART

Service restarting

Sent by Uvicorn at shutdown; reconnect after a delay.

1013 WS_1013_TRY_AGAIN_LATER

Try again later

Overload; ask the client to back off.

1006 WS_1006_ABNORMAL_CLOSURE

No close frame

Reserved: it is reported to an application, never sent on the wire.

The reason is optional text; the protocol limits control-frame payloads to 125 bytes, so it must stay under 123 bytes after the two-byte code. Keep it short and never put secrets or stack traces in it. The 4000 to 4999 range is for application-defined codes. The registry is the IANA page in References.

Always put cleanup in finally: it is the only place that runs on every path, including cancellation at server shutdown.

A Connection Manager for the Per-Book Chat

To send something to several clients you need to remember their sockets: the official docs call this a ConnectionManager and show a list of connections. The version for the Bookshelf API keeps one room per book, validates messages, broadcasts typed events and, an addition the official sketch lacks, drops a client that is dead or too slow instead of letting it block everyone:

import asyncio
from collections import defaultdict
from datetime import datetime, timezone
from typing import Annotated, Literal

from fastapi import FastAPI, Query, WebSocket
from pydantic import BaseModel, Field, ValidationError


class ChatIn(BaseModel):
    text: Annotated[str, Field(min_length=1, max_length=500)]


class ChatEvent(BaseModel):
    kind: Literal["join", "message", "leave", "error"]
    user: str
    text: str = ""
    at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))


class ConnectionManager:
    """Tracks the open sockets of each book's chat room, inside one process."""

    def __init__(self) -> None:
        self._rooms: dict[int, set[WebSocket]] = defaultdict(set)

    async def connect(self, book_id: int, websocket: WebSocket) -> None:
        await websocket.accept()
        self._rooms[book_id].add(websocket)

    def disconnect(self, book_id: int, websocket: WebSocket) -> None:
        room = self._rooms.get(book_id)
        if room is not None:
            room.discard(websocket)
            if not room:
                del self._rooms[book_id]

    def size(self, book_id: int) -> int:
        return len(self._rooms.get(book_id, ()))

    async def _send(self, book_id: int, websocket: WebSocket, data: str) -> None:
        try:
            await asyncio.wait_for(websocket.send_text(data), timeout=2.0)
        except Exception:  # dead or too slow: drop it, keep the room alive
            self.disconnect(book_id, websocket)

    async def broadcast(self, book_id: int, event: ChatEvent) -> None:
        await self.broadcast_raw(book_id, event.model_dump_json())

    async def broadcast_raw(self, book_id: int, data: str) -> None:
        sockets = list(self._rooms.get(book_id, ()))  # snapshot: the set changes while we await
        await asyncio.gather(*(self._send(book_id, ws, data) for ws in sockets))


manager = ConnectionManager()
app = FastAPI()


@app.websocket("/ws/books/{book_id}/chat")
async def book_chat(
    websocket: WebSocket, book_id: int, user: Annotated[str, Query(min_length=1, max_length=30)]
) -> None:
    await manager.connect(book_id, websocket)
    await manager.broadcast(book_id, ChatEvent(kind="join", user=user))
    try:
        async for raw in websocket.iter_json():
            try:
                incoming = ChatIn.model_validate(raw)
            except ValidationError:
                await websocket.send_text(
                    ChatEvent(kind="error", user="server", text="text must be 1-500 characters").model_dump_json()
                )
                continue
            await manager.broadcast(book_id, ChatEvent(kind="message", user=user, text=incoming.text))
    finally:
        manager.disconnect(book_id, websocket)
        await manager.broadcast(book_id, ChatEvent(kind="leave", user=user))

Points worth reading in the code:

  • broadcast_raw copies the room into a list before sending (list(self._rooms.get(…​))): a send awaits, and while it waits other clients may join or leave, changing the set during iteration.

  • asyncio.gather sends to all sockets concurrently, and asyncio.wait_for(…​, timeout=2.0) bounds each send, so one stalled client cannot stop the others from receiving. A client that raises or times out is removed.

  • The manager is created at module level because it holds per-process state; it is not shared between workers (see Scaling Across Workers: Redis Pub/Sub). Rooms are plain sets guarded by the event loop: every method that mutates them runs between await points, so no lock is needed.

  • The endpoint broadcasts join after the connection is registered and leave in finally. The user name is a query parameter here only to keep the demo short; Authentication for WebSockets replaces it with an authenticated identity.

  • Messages go out as JSON text produced by model_dump_json(), so datetimes are ISO strings and every event has the same shape for the client.

Driving it in-process with TestClient (entered as a context manager, which gives all connections one shared event loop; see Testing WebSockets) with three users in two rooms:

import json

from fastapi.testclient import TestClient

from chat import app, manager


def show(label, ws):
    event = json.loads(ws.receive_text())
    print(f"{label} <- {event['kind']:8} {event['user']:6} {event['text']!r}")


with TestClient(app) as client:  # one shared event loop for every connection
    with client.websocket_connect("/ws/books/7/chat?user=ana") as ana:
        show("ana", ana)
        with client.websocket_connect("/ws/books/7/chat?user=ben") as ben:
            show("ana", ana)
            show("ben", ben)
            with client.websocket_connect("/ws/books/8/chat?user=eve") as eve:
                show("eve", eve)  # a different book: sees only her own join
                ben.send_json({"text": "Loved the ending"})
                show("ana", ana)
                show("ben", ben)
                ana.send_json({"text": ""})
                show("ana", ana)
                print("room 7:", manager.size(7), "room 8:", manager.size(8))
print("after:", manager.size(7), manager.size(8))
ana <- join     ana    ''
ana <- join     ben    ''
ben <- join     ben    ''
eve <- join     eve    ''
ana <- message  ben    'Loved the ending'
ben <- message  ben    'Loved the ending'
ana <- error    server 'text must be 1-500 characters'
room 7: 2 room 8: 1
after: 0 0

Ana and Ben share book 7 and see each other’s events; Eve, in book 8, sees only her own join; an empty message is rejected with an error event sent to the sender only; both rooms are empty at the end.

The same server code against real Uvicorn, with two websockets clients (the leave event arrives because the server runs the finally block when Ben’s socket closes):

import asyncio, json, os, websockets

BASE = "ws://127.0.0.1:" + os.environ["PORT"]


async def show(label, ws):
    d = json.loads(await ws.recv())
    print(f"{label} <- {d['kind']:8} {d['user']:4} {d['text']!r}")


async def main():
    async with websockets.connect(f"{BASE}/ws/books/7/chat?user=ana") as ana:
        await show("ana", ana)
        async with websockets.connect(f"{BASE}/ws/books/7/chat?user=ben") as ben:
            await show("ana", ana); await show("ben", ben)
            await ben.send(json.dumps({"text": "Loved the ending"}))
            await show("ana", ana); await show("ben", ben)
        await show("ana", ana)  # ben left


asyncio.run(main())
ana <- join     ana  ''
ana <- join     ben  ''
ben <- join     ben  ''
ana <- message  ben  'Loved the ending'
ben <- message  ben  'Loved the ending'
ana <- leave    ben  ''

A Browser Client with Reconnect

Browsers use the standard WebSocket object (also global in Node.js 22 and later). Networks drop connections, and Uvicorn closes them with 1012 on every deploy, so a client must reconnect, with exponential backoff and jitter so that a restart does not turn into a stampede:

// Reconnecting chat client: exponential backoff with jitter, reset after a successful open.
function connectChat(bookId, user, onEvent, base = "ws://localhost:8000") {
  let attempt = 0;
  let socket;
  let stopped = false;

  function open() {
    socket = new WebSocket(`${base}/ws/books/${bookId}/chat?user=${encodeURIComponent(user)}`);
    socket.onopen = () => { attempt = 0; };
    socket.onmessage = (e) => onEvent(JSON.parse(e.data));
    socket.onclose = () => {
      if (stopped) return;
      const delay = Math.min(30000, 500 * 2 ** attempt++) * (0.5 + Math.random() / 2);
      setTimeout(open, delay);
    };
  }
  open();
  return {
    send: (text) => socket.send(JSON.stringify({ text })),
    close: () => { stopped = true; socket.close(); },
  };
}

Source: MDN: WebSocket

Run against the chat server above (with a small demo driver around the function) it printed:

join ana ""
message ana "Loved the ending"

Remember that a reconnect is a new connection: the server has forgotten the old one, so anything sent while the client was away is lost unless you persist it. The usual answer is to store messages (SQL Databases with SQLModel) and let the client ask for what it missed, for example with a last_seen query parameter like the one in Path, Query, Cookie, Header and Dependency Parameters.

Authentication for WebSockets

HTTPBearer, OAuth2PasswordBearer and the other security classes of Security Fundamentals read the Authorization header from a Request. Two things make WebSockets different:

  1. The browser WebSocket API cannot set request headers. Only the URL, the subprotocol list and the cookies go on the handshake.

  2. The security classes cannot be used as dependencies in a WebSocket handler. They declare a request: Request parameter that a WebSocket does not have. Used as they are, the run fails inside FastAPI:

    TypeError: OAuth2PasswordBearer.__call__() missing 1 required positional argument: 'request'

    (run, with OAuth2PasswordBearer as a dependency of @app.websocket). Read the header with Header() or websocket.headers and decode the token yourself, as in the examples below.

Authentication happens once, at connection time; afterwards a socket may live for hours, so the design question is how to get a credential onto the handshake and what to do when it expires. The options:

Table 4. Where the credential can travel
Option How Strengths Weaknesses

Cookie

The session cookie the browser already sends to the same site

Nothing to build; HttpOnly keeps it away from scripts

Cross-site WebSocket hijacking (the browser attaches cookies for any origin), so check Origin; the page and the API must be on the same site

Short-lived ticket in the query string

The client first calls an authenticated HTTP endpoint, then connects with ?ticket=…​

Works with a single-page app and a bearer-token API; the ticket lives seconds and is bound to one room

The URL ends up in access logs (hence the short life); one extra round trip

Authorization header

Header() in a dependency

The most conventional; fine for servers, CLIs and mobile apps

Browsers cannot do it

Subprotocol

The token rides in Sec-WebSocket-Protocol (websocket.scope["subprotocols"]) and the server accepts that subprotocol

Works in browsers without cookies or URLs

An abuse of the field; proxies and logs may record it; the server must echo exactly one offered value

First message

The client connects, then sends a message such as {"type": "auth", "token": …​} within a deadline

The credential never appears in a URL or header

The server must accept the handshake first; unauthenticated sockets consume resources until the deadline

A Ticket Flow with Origin Checking

The best fit for a single-page app that already holds a JWT from OAuth2 Password Flow & JWT is the ticket: a JWT that lives 30 seconds, is signed with its own key, names one user and one book, and is accepted only by the chat endpoint. The signing key comes from settings, as everywhere else in the section. The get_current_user below is a stand-in for the dependency of that page, so the example runs on its own. Run openssl rand -hex 32 to generate a real key; the script only creates a throwaway one so that it runs as is.

import os
import secrets
import time
from typing import Annotated

import jwt  # PyJWT
from fastapi import (Depends, FastAPI, Header, HTTPException, Query, WebSocket,
                     WebSocketException, status)
from pydantic import BaseModel
from pydantic_settings import BaseSettings, SettingsConfigDict

# Local demo only: a throwaway key generated for this run. In a real deployment it
# comes from the environment (for example: openssl rand -hex 32).
os.environ.setdefault("CHAT_TICKET_SECRET", secrets.token_hex(32))


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
    chat_ticket_secret: str
    chat_ticket_ttl_seconds: int = 30
    allowed_origins: list[str] = ["https://bookshelf.example.com"]


def get_settings() -> Settings:
    return Settings()


SettingsDep = Annotated[Settings, Depends(get_settings)]


# --- HTTP side: stand-in for the get_current_user dependency of the OAuth2 + JWT page -----
def get_current_user(authorization: Annotated[str | None, Header()] = None) -> str:
    if not authorization or not authorization.startswith("Bearer user:"):
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated",
                            headers={"WWW-Authenticate": "Bearer"})
    return authorization.removeprefix("Bearer user:")


class Ticket(BaseModel):
    ticket: str
    expires_in: int


app = FastAPI()


@app.post("/books/{book_id}/chat-ticket")
def issue_ticket(book_id: int, user: Annotated[str, Depends(get_current_user)],
                 settings: SettingsDep) -> Ticket:
    now = int(time.time())
    claims = {"sub": user, "book": book_id, "aud": "bookshelf-chat",
              "iat": now, "exp": now + settings.chat_ticket_ttl_seconds}
    token = jwt.encode(claims, settings.chat_ticket_secret, algorithm="HS256")
    return Ticket(ticket=token, expires_in=settings.chat_ticket_ttl_seconds)


# --- WebSocket side ------------------------------------------------------------------------
async def check_origin(websocket: WebSocket, settings: SettingsDep) -> None:
    origin = websocket.headers.get("origin")
    if origin is not None and origin not in settings.allowed_origins:  # browsers always send it
        raise WebSocketException(status.WS_1008_POLICY_VIOLATION, reason="origin not allowed")


async def chat_user(book_id: int, settings: SettingsDep,
                    ticket: Annotated[str | None, Query()] = None) -> str:
    if ticket is None:
        raise WebSocketException(status.WS_1008_POLICY_VIOLATION, reason="ticket required")
    try:
        claims = jwt.decode(ticket, settings.chat_ticket_secret, algorithms=["HS256"],
                            audience="bookshelf-chat", options={"require": ["exp", "sub", "book"]})
    except jwt.InvalidTokenError:
        raise WebSocketException(status.WS_1008_POLICY_VIOLATION, reason="invalid ticket")
    if claims["book"] != book_id:
        raise WebSocketException(status.WS_1008_POLICY_VIOLATION, reason="ticket is for another book")
    return claims["sub"]


@app.websocket("/ws/books/{book_id}/chat", dependencies=[Depends(check_origin)])
async def chat(websocket: WebSocket, book_id: int, user: Annotated[str, Depends(chat_user)]) -> None:
    await websocket.accept()
    await websocket.send_json({"welcome": user, "book": book_id})
    async for message in websocket.iter_json():
        await websocket.send_json({"user": user, "echo": message})

How it works:

  • POST /books/{book_id}/chat-ticket is an ordinary authenticated path operation. It returns a JWT whose claims carry sub (the user), book, aud (the audience that stops the ticket from being accepted elsewhere), iat and exp.

  • chat_user is a dependency of the WebSocket route. It decodes with an explicit algorithm list (algorithms=["HS256"]), requires the audience and the exp, sub and book claims, and checks that the book matches the room in the path. Any failure is a WebSocketException with 1008.

  • check_origin compares the Origin header with an allow-list from settings. Browsers always send Origin on a WebSocket handshake and page scripts cannot forge it; non-browser clients usually send none, so a missing header is allowed here (decide for your clients). Neither CORS nor CORSMiddleware protects a WebSocket: Starlette’s CORSMiddleware and GZipMiddleware pass WebSocket connections through untouched, so the origin check is your job. It is the defence against cross-site WebSocket hijacking, which matters most for the cookie option, and it is wired as dependencies=[Depends(check_origin)] on the route.

Using it, in-process:

401
{'ticket': 'eyJhbGciOi...', 'expires_in': 30}
OK   /ws/books/7/chat?ticket=eyJhbGciOiJIUzI1 {'welcome': 'ana', 'book': 7}
     {'user': 'ana', 'echo': {'text': 'hi'}}
OK   /ws/books/7/chat?ticket=eyJhbGciOiJIUzI1 {'welcome': 'ana', 'book': 7}
     {'user': 'ana', 'echo': {'text': 'hi'}}
DENY /ws/books/7/chat?ticket=eyJhbGciOiJIUzI1 1008 origin not allowed
DENY /ws/books/8/chat?ticket=eyJhbGciOiJIUzI1 1008 ticket is for another book
DENY /ws/books/7/chat 1008 ticket required
DENY /ws/books/7/chat?ticket=garbage 1008 invalid ticket
DENY /ws/books/7/chat?ticket=eyJhbGciOiJIUzI1 1008 invalid ticket

And over a real connection, with httpx for the HTTP call and websockets for the socket (Origin is set by the client library here; a browser sets it by itself):

import asyncio, json, os
import httpx, websockets

PORT = os.environ["PORT"]
http = httpx.Client(base_url=f"http://127.0.0.1:{PORT}")
reply = http.post("/books/7/chat-ticket", headers={"Authorization": "Bearer user:ana"})
print(reply.status_code, {k: v[:12] + "..." if k == "ticket" else v for k, v in reply.json().items()})
ticket = reply.json()["ticket"]


async def main():
    url = f"ws://127.0.0.1:{PORT}/ws/books/7/chat?ticket="
    async with websockets.connect(url + ticket, origin="https://bookshelf.example.com") as ws:
        print("welcome:", await ws.recv())
        await ws.send(json.dumps({"text": "hi"}))
        print("echo:   ", await ws.recv())
    for label, kwargs, t in [("foreign origin", {"origin": "https://evil.example.net"}, ticket),
                             ("no ticket", {}, "")]:
        try:
            async with websockets.connect(url + t, **kwargs):
                print(label, "connected")
        except websockets.InvalidStatus as e:
            print(label, "->", e.response.status_code)

asyncio.run(main())
200 {'ticket': 'eyJhbGciOiJI...', 'expires_in': 30}
welcome: {"welcome":"ana","book":7}
echo:    {"user":"ana","echo":{"text":"hi"}}
foreign origin -> 403
no ticket -> 403

Over the wire all the denials are HTTP 403, as explained in Closing, Disconnects and Close Codes; the close codes are visible only in-process. Note also what the server log recorded: the request line of the WebSocket, query string included. A ticket in a URL is a credential in a log; its 30-second life and single-room scope are what make that acceptable. Never put a long-lived access token in a query string.

The ticket authenticates the connection, not every message. For a long-lived socket decide how to handle expiry and revocation: close sockets on a timer (1008 with a reason the client understands), index the manager’s connections by user (dict[str, set[WebSocket]]) so a banned user can be closed from an HTTP endpoint, and re-check permissions for sensitive actions instead of trusting the check made at connect time. Authorization (may this user read this book’s chat? may they post?) belongs in the dependency; scopes work as on OAuth2 Scopes & Authorization. Always serve production WebSockets over wss:// (TLS), see Behind a Reverse Proxy: wss://.

Authenticating with the First Message

When no URL or cookie should carry the credential, accept the connection and make the first message the credential, with a deadline. Nothing else is served until it succeeds:

import anyio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, status
from fastapi.testclient import TestClient
from pydantic import BaseModel, ValidationError

AUTH_TIMEOUT_SECONDS = 0.3  # a few seconds in production


class AuthMessage(BaseModel):
    type: str
    token: str


def verify(token: str) -> str | None:
    """Stand-in for the JWT check of the OAuth2 + JWT page."""
    return token.removeprefix("user:") if token.startswith("user:") else None


app = FastAPI()


@app.websocket("/ws/first-message")
async def first_message(websocket: WebSocket) -> None:
    await websocket.accept()  # accepted, but nothing is served before authentication
    user = None
    with anyio.move_on_after(AUTH_TIMEOUT_SECONDS):
        try:
            auth = AuthMessage.model_validate(await websocket.receive_json())
        except (ValidationError, ValueError):
            auth = None
        if auth is not None and auth.type == "auth":
            user = verify(auth.token)
    if user is None:
        await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="authentication required")
        return
    await websocket.send_json({"authenticated": user})
    async for message in websocket.iter_json():
        await websocket.send_json({"echo": message})


client = TestClient(app)


def run(label, first=None):
    try:
        with client.websocket_connect("/ws/first-message") as ws:
            if first is not None:
                ws.send_json(first)
            print(label, ws.receive_json())
    except WebSocketDisconnect as exc:
        print(label, "closed", exc.code, exc.reason)


run("good   ", {"type": "auth", "token": "user:ana"})
run("bad    ", {"type": "auth", "token": "nope"})
run("garbage", {"hello": 1})
run("silent ")
good    {'authenticated': 'ana'}
bad     closed 1008 authentication required
garbage closed 1008 authentication required
silent  closed 1008 authentication required

Good, bad, malformed and silent clients all end the same way. anyio.move_on_after (AnyIO is a Starlette dependency) turns "no message within the deadline" into user is None; receive_json() raising ValueError for non-JSON (the JSONDecodeError seen earlier is a subclass) is handled in the same branch.

Subprotocol Tokens

Starlette’s own documentation shows the subprotocol approach: the client offers ["Authorization", token], the server reads websocket.scope["subprotocols"], validates them and accepts with accept("Authorization"). It is the only way to send a credential from a browser without cookies or a URL, at the price of putting it in a header that proxies and logs are not expecting. Prefer the ticket; use this only when you cannot.

Limits: Idle Timeouts, Rate Limits and Message Size

An open socket costs memory, a file descriptor and a task for as long as it lives, and a client can send as fast as the network allows. Four protections belong in every public WebSocket service:

  • a maximum message size;

  • an idle timeout for sockets that go quiet without closing;

  • a per-connection rate limit;

  • ping/pong so that dead peers are detected.

Uvicorn handles the last and part of the first at the protocol level:

Table 5. Uvicorn WebSocket settings
Option Default Effect

--ws-max-size

16777216 (16 MiB)

Largest message accepted; a larger one closes the connection with code 1009 (run: --ws-max-size 50 and a 100-byte message gave 1009 'frame exceeds limit of 50 bytes'). Lower it for a chat.

--ws-max-queue

32

Length of the incoming message queue (legacy --ws websockets implementation only; ignored by auto, which selects websockets-sansio when the websockets package is installed).

--ws-ping-interval

20.0 seconds

How often the server sends a ping frame. Pings keep proxies from timing out an idle connection and detect dead peers.

--ws-ping-timeout

20.0 seconds

How long to wait for the pong before closing.

--ws-per-message-deflate

true

Per-message compression (honoured by the websockets, websockets-sansio and wsproto implementations); turn it off to save CPU when messages are small.

--ws

auto

Implementation: auto, none, websockets, websockets-sansio, wsproto.

A browser answers pings automatically, and a quiet but connected client is not dead, so an idle timeout is about application activity. An idle timeout and a sliding-window rate limit, with a size check on top, fit in the handler:

import time
from collections import deque

import anyio
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, status
from fastapi.testclient import TestClient

IDLE_SECONDS = 0.3        # 60 or more in production
MAX_MESSAGES = 3          # per window
WINDOW_SECONDS = 1.0
MAX_TEXT_BYTES = 1024


class SlidingWindow:
    """At most `limit` events per `window` seconds, for one connection."""

    def __init__(self, limit: int, window: float) -> None:
        self.limit, self.window = limit, window
        self.events: deque[float] = deque()

    def allow(self) -> bool:
        now = time.monotonic()
        while self.events and now - self.events[0] > self.window:
            self.events.popleft()
        if len(self.events) >= self.limit:
            return False
        self.events.append(now)
        return True


app = FastAPI()


@app.websocket("/ws/guarded")
async def guarded(websocket: WebSocket) -> None:
    await websocket.accept()
    limiter = SlidingWindow(MAX_MESSAGES, WINDOW_SECONDS)
    while True:
        try:
            with anyio.fail_after(IDLE_SECONDS):
                text = await websocket.receive_text()
        except TimeoutError:
            await websocket.close(code=status.WS_1001_GOING_AWAY, reason="idle timeout")
            return
        except WebSocketDisconnect:
            return
        if len(text.encode()) > MAX_TEXT_BYTES:
            await websocket.close(code=status.WS_1009_MESSAGE_TOO_BIG, reason="message too big")
            return
        if not limiter.allow():
            await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="rate limit exceeded")
            return
        await websocket.send_text(f"ok: {text}")


client = TestClient(app)


def run(label, messages, wait=0.0):
    try:
        with client.websocket_connect("/ws/guarded") as ws:
            for m in messages:
                ws.send_text(m)
                print(label, ws.receive_text())
            time.sleep(wait)
            ws.receive_text()
    except WebSocketDisconnect as exc:
        print(label, "closed", exc.code, exc.reason)


run("flood", ["a", "b", "c", "d"])
run("big  ", ["x" * 2000])
run("idle ", ["hello"], wait=0.6)
flood ok: a
flood ok: b
flood ok: c
flood closed 1008 rate limit exceeded
big   closed 1009 message too big
idle  ok: hello
idle  closed 1001 idle timeout

Flooding is cut off at the fourth message in a second with 1008; a 2000-byte frame against a 1024-byte limit closes with 1009; a connection that sends nothing for 0.3 seconds (60 seconds or more in production) is closed with 1001. The limiter is per connection and per process. To limit a user across several connections and workers, count in Redis; the fixed-window limiter in the Redis rate-limit example works the same way with the user id as the key.

On the sending side, await websocket.send_text() returns when the data is handed to the transport, so a slow client that never reads makes buffers grow instead of failing quickly. That is why the manager bounds every send with a timeout and drops stragglers; if a client genuinely needs every message, give each connection its own bounded queue and writer task, and close the connection when the queue is full.

Do Not Hold a Database Session for the Life of a Socket

Dependencies with yield work in WebSocket handlers, but their exit code runs when the connection ends, possibly hours later:

from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import Depends, FastAPI, WebSocket
from fastapi.testclient import TestClient

events: list[str] = []


async def get_session() -> AsyncIterator[str]:
    events.append("session opened")
    try:
        yield "session"
    finally:
        events.append("session closed")


SessionDep = Annotated[str, Depends(get_session)]
app = FastAPI()


@app.websocket("/ws")
async def ws(websocket: WebSocket, session: SessionDep) -> None:
    await websocket.accept()
    events.append("accepted")
    async for text in websocket.iter_text():
        events.append(f"message {text}")
    events.append("loop ended")


client = TestClient(app)
with client.websocket_connect("/ws") as w:
    w.send_text("one")
    w.send_text("two")
print(events)
['session opened', 'accepted', 'message one', 'message two', 'loop ended', 'session closed']

session closed comes after loop ended. A Depends(get_session) on a chat socket therefore pins one pool connection per open socket, and a few hundred chat users exhaust the pool. Open a session per message inside the loop instead (with Session(engine) as session:), as on SQL Databases with SQLModel, or use dependencies that yield something cheap. Dependencies with yield explains the lifecycle in detail.

Scaling Across Workers: Redis Pub/Sub

ConnectionManager lives in the memory of one process. Run uvicorn --workers 4 (or several containers) and each worker has its own manager; Ana, connected to worker A, never sees a message from Ben, connected to worker B, because broadcast only reaches sockets in the same process. The official docs say it plainly: an in-memory list works only while the process is running and only with a single process, and they point to encode/broadcaster, backed by Redis, PostgreSQL or others, for something more robust.

The cure is a shared message bus. Every worker publishes each event to the bus and subscribes to the same bus; each worker forwards what it hears to its own sockets. Redis pub/sub is the simplest bus.

flowchart LR subgraph wa["Worker A"] direction TB ma["ConnectionManager A
sockets of book 7: Ana"] ha["ChatHub A"] end subgraph wb["Worker B"] direction TB mb["ConnectionManager B
sockets of book 7: Ben"] hb["ChatHub B"] end R[("Redis
channel book:7")] ben(["Ben sends a message"]) --> hb hb -- "PUBLISH book:7" --> R R -- "pmessage" --> ha R -- "pmessage" --> hb ha --> ma hb --> mb ma --> ana(["Ana receives"]) mb --> ben2(["Ben receives"])
Figure 2. Fan-out through Redis pub/sub: every worker relays to its own sockets
import asyncio
import contextlib
from collections.abc import AsyncIterator
from typing import Annotated

import redis.asyncio as redis
from fastapi import FastAPI, Query, WebSocket

from chat import ChatEvent, ChatIn, ConnectionManager  # the per-process manager from the previous section


class ChatHub:
    """Publishes every chat event to Redis; each process relays what it hears to its own sockets."""

    def __init__(self, client: redis.Redis, local: ConnectionManager) -> None:
        self.client = client
        self.local = local
        self._pubsub = client.pubsub()
        self._task: asyncio.Task[None] | None = None

    async def start(self) -> None:
        await self._pubsub.psubscribe("book:*")
        self._task = asyncio.create_task(self._relay())

    async def stop(self) -> None:
        if self._task is not None:
            self._task.cancel()
            with contextlib.suppress(asyncio.CancelledError):
                await self._task
        await self._pubsub.aclose()

    async def publish(self, book_id: int, event: ChatEvent) -> None:
        await self.client.publish(f"book:{book_id}", event.model_dump_json())

    async def _relay(self) -> None:
        async for message in self._pubsub.listen():
            if message["type"] != "pmessage":
                continue
            book_id = int(message["channel"].removeprefix("book:"))
            await self.local.broadcast_raw(book_id, message["data"])


def create_app(client: redis.Redis) -> FastAPI:
    @contextlib.asynccontextmanager
    async def lifespan(app: FastAPI) -> AsyncIterator[dict]:
        hub = ChatHub(client, ConnectionManager())
        await hub.start()
        yield {"hub": hub}
        await hub.stop()

    app = FastAPI(lifespan=lifespan)

    @app.websocket("/ws/books/{book_id}/chat")
    async def chat(websocket: WebSocket, book_id: int,
                   user: Annotated[str, Query(min_length=1, max_length=30)]) -> None:
        hub: ChatHub = websocket.state.hub
        await hub.local.connect(book_id, websocket)
        await hub.publish(book_id, ChatEvent(kind="join", user=user))
        try:
            async for raw in websocket.iter_json():
                incoming = ChatIn.model_validate(raw)
                await hub.publish(book_id, ChatEvent(kind="message", user=user, text=incoming.text))
        finally:
            hub.local.disconnect(book_id, websocket)
            await hub.publish(book_id, ChatEvent(kind="leave", user=user))

    return app

Notes on the design:

  • The handler never broadcasts directly. It calls hub.publish, and the event comes back through the subscription, to this worker and every other. That keeps a single code path and a single ordering of events per channel.

  • One channel per book (book:7) and one pattern subscription (psubscribe("book:*")) per worker, not one subscription per socket: the number of Redis subscriptions grows with workers, not with users.

  • The subscriber task starts in the lifespan (see Settings, Lifespan & Background Tasks) and is cancelled and awaited on shutdown. yield {"hub": hub} stores the hub in lifespan state, readable as websocket.state.hub. await self._pubsub.psubscribe(…​) completes before the app serves, so no event published after startup is missed.

  • The client is passed to the application factory, so tests substitute it. Creating the real client (URL from settings, closing it in the lifespan) is shown on NoSQL & Other Data Stores.

Two apps sharing one in-memory fake Redis stand in for two workers in the run below (the FakeServer of the fakeredis package; the application code is the same, only the client differs). Ana is connected to worker A, Ben to worker B:

import json
import fakeredis
from fakeredis import FakeAsyncRedis
from fastapi.testclient import TestClient
from hub_chat import create_app

server = fakeredis.FakeServer()  # stands in for one shared Redis; two apps = two workers
worker_a = create_app(FakeAsyncRedis(server=server, decode_responses=True))
worker_b = create_app(FakeAsyncRedis(server=server, decode_responses=True))


def show(label, ws):
    d = json.loads(ws.receive_text())
    print(f"{label} <- {d['kind']:8} {d['user']:4} {d['text']!r}")


with TestClient(worker_a) as a, TestClient(worker_b) as b:
    with a.websocket_connect("/ws/books/7/chat?user=ana") as ana:
        show("ana@A", ana)
        with b.websocket_connect("/ws/books/7/chat?user=ben") as ben:
            show("ana@A", ana); show("ben@B", ben)
            ben.send_json({"text": "Loved the ending"})
            show("ana@A", ana); show("ben@B", ben)
            ana.send_json({"text": "Same here"})
            show("ana@A", ana); show("ben@B", ben)
ana@A <- join     ana  ''
ana@A <- join     ben  ''
ben@B <- join     ben  ''
ana@A <- message  ben  'Loved the ending'
ben@B <- message  ben  'Loved the ending'
ana@A <- message  ana  'Same here'
ben@B <- message  ana  'Same here'

Every message reaches both users, whichever worker holds their socket. The fake stands in for the redis-py pub/sub API; repeat the same flow against a real Redis in your integration tests.

Redis pub/sub is fire and forget: a message goes only to the subscribers connected at that moment, with no storage, no acknowledgements and no replay. A worker that restarts misses what happened meanwhile, and a client that reconnects needs its history from the database. When delivery must be reliable, use Redis Streams or a message broker; see Redis Streams and Pub/Sub and Messaging. Other points that decide whether a design scales:

  • Sticky sessions are not needed once events travel through the bus, and a reconnect may land on any worker.

  • Presence ("who is in this room") must be shared too, for example in a Redis set with expiry, or each worker publishes join and leave events as above and clients maintain the list.

  • Backpressure: a very busy channel can produce more events than one worker can fan out; the bounded-send logic of the manager applies here as well.

  • Deployment: workers, containers, and how many connections one process can hold (file descriptor limits, memory per connection) are on Deployment.

Behind a Reverse Proxy: wss://

In production, TLS usually terminates at a proxy and the browser connects to wss:// (WebSocket over TLS, the counterpart of https); a page served over HTTPS cannot open plain ws:// connections. The proxy must pass the upgrade through. In nginx, Upgrade and Connection are hop-by-hop headers and are not forwarded unless you set them:

location /ws/ {
    proxy_pass http://backend;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 120s;
}

By default nginx closes the connection if the proxied server sends no data for 60 seconds (proxy_read_timeout); either raise it or let the server send pings (Uvicorn does, every 20 seconds by default). Before nginx 1.29.7 the nginx page also asks for proxy_http_version 1.1;. This snippet comes from the nginx documentation and was not run here. Other proxies and load balancers have their own idle timeouts (often 60 seconds), so test the ping interval through the real chain. For forwarded headers, root_path and TLS termination see Running Behind a Proxy and HTTPS and TLS Termination. HTTPSRedirectMiddleware does apply to WebSocket connections (it redirects ws to wss), while CORS and GZip do not.

OpenAPI and the Interactive Docs

WebSocket routes are not part of the OpenAPI document (the paths printed by the first-endpoint run above is empty, although the route works) and /docs cannot open them; OpenAPI describes request/response operations. Dependencies and parameters used only by WebSocket routes do not appear either. Document the endpoint by hand: the URL, query parameters, close codes and message shapes (your Pydantic models) in a module docstring, the README, or an AsyncAPI document if your organization uses one. You can still publish the message models in OpenAPI by returning them from an ordinary endpoint (for example GET /books/{book_id}/chat/schema) or listing them in responses=, so generated clients get the types; see OpenAPI & Interactive Docs.

To try a socket by hand use a small script (the websockets client used above), the interactive client of that package (python -m websockets ws://localhost:8000/ws/echo), a browser console, or any tool with WebSocket support.

Testing WebSockets

TestClient.websocket_connect(url, subprotocols=None, **kwargs) returns a context manager; inside it you call send_text, send_bytes, send_json, receive_text, receive_bytes, receive_json and close. Extra keyword arguments (headers, params, cookies, …​) are passed to the HTTP request that makes the handshake. A handshake the server rejects, or a close the server initiates, raises WebSocketDisconnect carrying code and reason:

import secrets

import pytest
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect

from auth_chat import Settings, app, get_settings
from chat import app as room_app

TEST_SETTINGS = Settings(chat_ticket_secret=secrets.token_hex(32), chat_ticket_ttl_seconds=30)


@pytest.fixture
def client():
    app.dependency_overrides[get_settings] = lambda: TEST_SETTINGS
    with TestClient(app) as c:
        yield c
    app.dependency_overrides.clear()


def ticket_for(client, book_id, user="ana"):
    response = client.post(f"/books/{book_id}/chat-ticket", headers={"Authorization": f"Bearer user:{user}"})
    assert response.status_code == 200
    return response.json()["ticket"]


def test_connect_send_receive(client):
    with client.websocket_connect(f"/ws/books/7/chat?ticket={ticket_for(client, 7)}") as ws:
        assert ws.receive_json() == {"welcome": "ana", "book": 7}
        ws.send_json({"text": "hi"})
        assert ws.receive_json() == {"user": "ana", "echo": {"text": "hi"}}


def test_ticket_for_another_book_is_refused(client):
    with pytest.raises(WebSocketDisconnect) as exc:
        with client.websocket_connect(f"/ws/books/8/chat?ticket={ticket_for(client, 7)}"):
            pass
    assert exc.value.code == 1008
    assert exc.value.reason == "ticket is for another book"


def test_foreign_origin_is_refused(client):
    with pytest.raises(WebSocketDisconnect) as exc:
        with client.websocket_connect(f"/ws/books/7/chat?ticket={ticket_for(client, 7)}",
                                      headers={"origin": "https://evil.example.net"}):
            pass
    assert exc.value.code == 1008


def test_room_broadcast_reaches_everyone_in_the_book():
    with TestClient(room_app) as c:  # one shared event loop, so two sockets can talk
        with c.websocket_connect("/ws/books/1/chat?user=ana") as ana, \
             c.websocket_connect("/ws/books/1/chat?user=ben") as ben:
            ana.receive_json(); ana.receive_json(); ben.receive_json()  # join events
            ben.send_json({"text": "hello"})
            assert ana.receive_json()["text"] == "hello"
            assert ben.receive_json()["text"] == "hello"
....                                                                     [100%]
4 passed in 0.41s

What the examples above rely on:

  • dependency_overrides replaces get_settings, so no real key is read; the tests generate a random key per run.

  • with TestClient(app) as client: runs the lifespan and, important for a chat, gives all connections of that client one shared event loop. Without it each websocket_connect runs in its own thread with its own event loop, and a manager that sends from one connection to another then hangs: the first run of the chat example for this page did exactly that until it was switched to the context-manager form. A connection manager can only be tested with several sockets in this form.

  • Pre-accept rejections show up as WebSocketDisconnect in the test; against a real server they are HTTP 403 (see Closing, Disconnects and Close Codes). Keep a small live test for handshake behaviour.

  • receive_* blocks forever if the server never sends; guard long waits with a timeout in the test runner.

  • TestClient cannot show ping/pong, compression, proxy behaviour or timing; use a real Uvicorn plus the websockets client for those.

  • Starlette 1.7 prints StarletteDeprecationWarning: Using httpx with starlette.testclient is deprecated; install httpx2 instead when the test client runs on plain httpx (all runs on this page did); the Testing page covers the choice.

Fixtures, overrides, async tests and the rest of TestClient are on Testing; the same patterns apply to WebSockets.

WebSockets, SSE or Polling?

Table 6. Choosing a transport
WebSockets Server-Sent Events Polling

Direction

Both, at any time

Server to client; the client uses ordinary requests

The client asks, the server answers

Browser API

WebSocket

EventSource (reconnects and resumes by itself)

fetch

Through proxies and CDNs

Needs upgrade support and idle timeouts configured

Plain HTTP; buffering must be off

Always works

Authentication

Cookie, ticket or first message (no custom headers in browsers)

Cookie or a fetch-based client

Any header

Delivery guarantees

None built in; you design acknowledgements

Last-Event-ID resume

Whatever the API provides

Choose it for

Chat, games, collaborative editing, anything the client sends to continuously

Notifications, live feeds, progress, token streaming

Rare updates, simplest operations

For the Bookshelf chat, WebSockets fit because users both read and write. For a "new review" feed on a book page, SSE is the better fit (see Streaming, JSON Lines & SSE).

Where Older Guides Are Outdated, and What Changed Recently

  • Dependencies and parameters. FastAPI has supported parameters and dependencies in WebSocket routes since 0.24.0 (2019-05-24), yet older tutorials read the token by hand from websocket.query_params. Use Depends, Query, Cookie and Header, and put checks in dependencies so they are reusable and testable.

  • HTTPException in WebSocket code. Several guides raise HTTPException from WebSocket dependencies; there is no HTTP status to produce there. WebSocketException has been available since 0.87.0 (2022-11-13), and since 0.97.0 (2023-06-11) WebSocketRequestValidationError can have its own handler.

  • Unauthenticated, origin-blind endpoints. The official example is deliberately minimal. Production endpoints need authentication, an Origin check, size and rate limits, and a wss:// front.

  • Rooms in a global list. The documentation’s single-list manager works in a single process only; with several workers use a shared bus (Scaling Across Workers: Redis Pub/Sub).

  • A while True receive loop. It logs an error on every normal disconnect; use iter_* or catch WebSocketDisconnect.

  • websockets taken for granted. Plain uvicorn has no WebSocket library, and guides that assume uvicorn[standard] omit the step. fastapi[standard] includes it.

  • Annotated. Since 0.95.0 (2023-03-18) the recommended spelling of Query(), Cookie() and Depends() is Annotated[…​], also in WebSocket handlers.

  • Testing with several clients. Many examples open a single test socket; manager-style code needs the context-manager form of TestClient (Testing WebSockets).

Table 7. Version history relevant to WebSockets
Version Date Change

0.24.0

2019-05-24

Support for WebSockets with dependencies and parameters.

0.61.0

2020-08-09

Injection of HTTPConnection (as Request or WebSocket) into dependencies; WebSocketDisconnect exported from fastapi.

0.75.2

2022-04-17

Prefix on APIRouter WebSockets fixed.

0.82.0

2022-09-04

WebSocketState exported from fastapi.websockets; path parameters in WebSockets fixed.

0.87.0

2022-11-13

WebSocketException, re-exported from Starlette.

0.95.0

2023-03-18

Annotated supported and recommended.

0.97.0

2023-06-11

Dependencies in WebSocket routes; exception handler for WebSocketRequestValidationError.

0.133.0

2026-02-24

Starlette 1.0.0 and later supported (the current release uses Starlette 1.7).

Pitfalls

  • Sending or receiving before accept() raises an error; closing before accept() is a handshake rejection (HTTP 403), not a close frame.

  • A while True: await websocket.receive_text() loop logs Exception in ASGI application on every normal disconnect.

  • HTTPException is not an error channel here; use WebSocketException and a close code.

  • Browsers cannot send an Authorization header on a WebSocket, and OAuth2PasswordBearer and its siblings raise TypeError in a WebSocket route.

  • A token in the query string is written to access logs; use a ticket that lives seconds and is bound to one room.

  • CORS does not apply to WebSockets: check Origin yourself, especially when using cookies.

  • Not validating messages: receive_json() returns anything the client sent, or raises JSONDecodeError.

  • Broadcasting by iterating the live set of connections, or awaiting each send in turn: one dead or slow client then blocks or breaks everyone. Snapshot the set, send concurrently and bound each send.

  • Forgetting that a manager is per process: with several workers, events stop at the process boundary.

  • Redis pub/sub is fire and forget: a reconnecting client needs history from storage.

  • Holding a database session in a yield dependency for the life of the connection pins a pool connection per socket.

  • An unbounded per-client queue or an unlimited message size is a memory problem waiting for one misbehaving client.

  • Proxies close idle connections (nginx after 60 seconds by default); keep pings on or raise the timeout.

  • Putting secrets, stack traces or long text in the close reason, which is limited to 123 bytes.

  • Testing a multi-connection manager with a TestClient that is not used as a context manager hangs.

  • Relying on /docs to exercise WebSockets; it cannot.