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().
| 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 ( |
Close code and short reason ( |
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.
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).
Source: Uvicorn: WebSockets
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; callingclose()instead rejects it. -
Keep the function running. When it returns, FastAPI closes the connection; an
async fororwhile Trueloop 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"]))
Source: Starlette: WebSockets
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:
| Receive | Send | Notes |
|---|---|---|
|
|
Text frames. A binary frame raises an error here. |
|
|
Binary frames, such as a cover image or a protobuf message. |
|
|
JSON over text frames by default; pass |
|
|
The raw ASGI message. Use these, never the raw |
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())
Source: FastAPI: WebSockets and Pydantic: Models
{'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
intand aQuery(ge=0)constraint are enforced exactly as for HTTP. -
get_usernamedeclares awebsocket: WebSocketparameter. A dependency may ask for theWebSocket(or the more generalHTTPConnection, 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
TestClientis 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 forWebSocketRequestValidationError, which, since 0.97.0, can be overridden likeRequestValidationError:
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())
Source: websockets: asyncio client and Uvicorn: WebSockets
/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 HTTP403. The client never gets a close code, onlyHTTP 403, whatevercodeyou passed (theTestClientshows the code and reason instead, one reason to also test against a real server). The same goes for aWebSocketExceptionor a validation failure raised beforeaccept(), which covers every dependency in the examples above. Starlette can send a different HTTP status withsend_denial_response()when the server supports the denial-response extension. -
WebSocketExceptionafteraccept()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 logsException 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. Useiter_text()oriter_json(), or catch the exception. -
Uvicorn’s own shutdown closes open sockets with
1012(service restart) and logs the unhandledWebSocketDisconnectof loops that do not catch it (run:closed 1012on the client after sending SIGINT to the server). Clients should treat1012as "reconnect".
| Code and constant | Meaning | Use in an application |
|---|---|---|
|
Normal closure |
The default for |
|
Endpoint going away |
Idle timeouts, server restarts, a page navigation. |
|
Policy violation |
Authentication or authorization failure, rate limit exceeded, invalid parameters. |
|
Message too big |
A frame above your limit. Uvicorn closes with it when |
|
Unexpected server error |
Closing deliberately after catching a bug you cannot recover from. |
|
Service restarting |
Sent by Uvicorn at shutdown; reconnect after a delay. |
|
Try again later |
Overload; ask the client to back off. |
|
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_rawcopies the room into a list before sending (list(self._rooms.get(…))): asendawaits, and while it waits other clients may join or leave, changing the set during iteration. -
asyncio.gathersends to all sockets concurrently, andasyncio.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
awaitpoints, so no lock is needed. -
The endpoint broadcasts
joinafter the connection is registered andleaveinfinally. 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))
Source: FastAPI: Testing WebSockets
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())
Source: websockets: asyncio client
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:
-
The browser
WebSocketAPI cannot set request headers. Only the URL, the subprotocol list and the cookies go on the handshake. -
The security classes cannot be used as dependencies in a WebSocket handler. They declare a
request: Requestparameter 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
OAuth2PasswordBeareras a dependency of@app.websocket). Read the header withHeader()orwebsocket.headersand 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:
| Option | How | Strengths | Weaknesses |
|---|---|---|---|
Cookie |
The session cookie the browser already sends to the same site |
Nothing to build; |
Cross-site WebSocket hijacking (the browser attaches cookies for any origin), so check |
Short-lived ticket in the query string |
The client first calls an authenticated HTTP endpoint, then connects with |
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 |
|
|
The most conventional; fine for servers, CLIs and mobile apps |
Browsers cannot do it |
Subprotocol |
The token rides in |
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 |
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})
Source: FastAPI: WebSockets — Using Depends and others and FastAPI: OAuth2 with Password (and hashing), Bearer with JWT tokens
How it works:
-
POST /books/{book_id}/chat-ticketis an ordinary authenticated path operation. It returns a JWT whose claims carrysub(the user),book,aud(the audience that stops the ticket from being accepted elsewhere),iatandexp. -
chat_useris a dependency of the WebSocket route. It decodes with an explicit algorithm list (algorithms=["HS256"]), requires the audience and theexp,subandbookclaims, and checks that the book matches the room in the path. Any failure is aWebSocketExceptionwith1008. -
check_origincompares theOriginheader with an allow-list from settings. Browsers always sendOriginon 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 norCORSMiddlewareprotects a WebSocket: Starlette’sCORSMiddlewareandGZipMiddlewarepass 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 asdependencies=[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())
Source: HTTPX and websockets: asyncio client
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:
| Option | Default | Effect |
|---|---|---|
|
16777216 (16 MiB) |
Largest message accepted; a larger one closes the connection with code |
|
32 |
Length of the incoming message queue (legacy |
|
20.0 seconds |
How often the server sends a ping frame. Pings keep proxies from timing out an idle connection and detect dead peers. |
|
20.0 seconds |
How long to wait for the pong before closing. |
|
true |
Per-message compression (honoured by the |
|
|
Implementation: |
Source: Uvicorn: Settings
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)
Source: FastAPI: Dependencies with yield
['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.
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"])
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
Source: redis-py: Asyncio examples and Redis: Pub/Sub
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 aswebsocket.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)
Source: redis-py: Asyncio examples and fakeredis on PyPI
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
joinandleaveevents 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;
}
Source: nginx: WebSocket proxying
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"
Source: FastAPI: Testing WebSockets
.... [100%]
4 passed in 0.41s
What the examples above rely on:
-
dependency_overridesreplacesget_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 eachwebsocket_connectruns 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
WebSocketDisconnectin the test; against a real server they areHTTP 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. -
TestClientcannot show ping/pong, compression, proxy behaviour or timing; use a real Uvicorn plus thewebsocketsclient for those. -
Starlette 1.7 prints
StarletteDeprecationWarning: Using httpx with starlette.testclient is deprecated; install httpx2 insteadwhen the test client runs on plainhttpx(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?
| 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 |
|
|
|
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 |
|
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. UseDepends,Query,CookieandHeader, and put checks in dependencies so they are reusable and testable. -
HTTPExceptionin WebSocket code. Several guides raiseHTTPExceptionfrom WebSocket dependencies; there is no HTTP status to produce there.WebSocketExceptionhas been available since 0.87.0 (2022-11-13), and since 0.97.0 (2023-06-11)WebSocketRequestValidationErrorcan have its own handler. -
Unauthenticated, origin-blind endpoints. The official example is deliberately minimal. Production endpoints need authentication, an
Origincheck, size and rate limits, and awss://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 Truereceive loop. It logs an error on every normal disconnect; useiter_*or catchWebSocketDisconnect. -
websocketstaken for granted. Plainuvicornhas no WebSocket library, and guides that assumeuvicorn[standard]omit the step.fastapi[standard]includes it. -
Annotated. Since 0.95.0 (2023-03-18) the recommended spelling ofQuery(),Cookie()andDepends()isAnnotated[…], 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).
| Version | Date | Change |
|---|---|---|
0.24.0 |
2019-05-24 |
Support for WebSockets with dependencies and parameters. |
0.61.0 |
2020-08-09 |
Injection of |
0.75.2 |
2022-04-17 |
Prefix on |
0.82.0 |
2022-09-04 |
|
0.87.0 |
2022-11-13 |
|
0.95.0 |
2023-03-18 |
|
0.97.0 |
2023-06-11 |
Dependencies in WebSocket routes; exception handler for |
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 beforeaccept()is a handshake rejection (HTTP403), not a close frame. -
A
while True: await websocket.receive_text()loop logsException in ASGI applicationon every normal disconnect. -
HTTPExceptionis not an error channel here; useWebSocketExceptionand a close code. -
Browsers cannot send an
Authorizationheader on a WebSocket, andOAuth2PasswordBearerand its siblings raiseTypeErrorin 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
Originyourself, especially when using cookies. -
Not validating messages:
receive_json()returns anything the client sent, or raisesJSONDecodeError. -
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
TestClientthat is not used as a context manager hangs. -
Relying on
/docsto exercise WebSockets; it cannot.