DEV Community

Cover image for Type-safe, scalable WebSockets in Python: a FastAPI tutorial
Huy Nguyen
Huy Nguyen

Posted on Edited on AI-assisted

Type-safe, scalable WebSockets in Python: a FastAPI tutorial

This is the error this post builds up to:

src/chat.ts(8,60): error TS2339: Property 'text' does not exist on type 'ChatNotificationPayload'.
Enter fullscreen mode Exit fullscreen mode

The Python server renamed a WebSocket field from text to body, and the TypeScript frontend found out at compile time, through a client generated from the server's code. Without that client the field reads as undefined, nothing throws, and the user sees an empty chat bubble. The same kind of bug (content on one side, body on the other) once cost me most of a day, and it's how I opened my talk at AsyncAPI Conf 2026, which covers the approach behind this post.

FastAPI already gives HTTP routes this kind of safety: type hints become an OpenAPI schema, a /docs page and generated clients. @app.websocket gets none of it. You receive a dict, check a string and send a dict back, and nothing describes those messages.

So in this post we build a small FastAPI room chat where every WebSocket message is a Pydantic model. From those models we get validation, an AsyncAPI docs page, and typed TypeScript and Python clients. Then we move it onto Redis so it keeps working when you run more than one server, and push messages into it from a plain HTTP endpoint. It takes about 15 minutes, and every command and output below comes from a real run (chanx 2.13.0, FastAPI 0.162, @chanx-js/client 2.3, Redis 7).

Setup

mkdir room-chat && cd room-chat
python -m venv .venv && source .venv/bin/activate
pip install "chanx[fast_channels]" uvicorn websockets
Enter fullscreen mode Exit fullscreen mode

The library doing the work is chanx, which I maintain. It also runs on Django Channels and other ASGI frameworks, but this post sticks to FastAPI.

1. Messages are Pydantic models

Each message has a constant action field and a typed payload. Create main.py:

from typing import Literal

from fastapi import FastAPI, Request
from fast_channels.layers import InMemoryChannelLayer, register_channel_layer
from pydantic import BaseModel, Field

from chanx.core.decorators import channel, ws_handler
from chanx.fast_channels import asyncapi_docs, asyncapi_spec_json
from chanx.fast_channels.websocket import AsyncJsonWebsocketConsumer
from chanx.messages.base import BaseMessage
from chanx.messages.incoming import PingMessage
from chanx.messages.outgoing import PongMessage


class ChatPayload(BaseModel):
    text: str = Field(min_length=1, max_length=500)


class ChatMessage(BaseMessage):
    """A message a user posts to the room."""

    action: Literal["chat"] = "chat"
    payload: ChatPayload


class ChatNotificationPayload(BaseModel):
    room: str
    text: str


class ChatNotification(BaseMessage):
    """A message broadcast to everyone in the room."""

    action: Literal["chat_notification"] = "chat_notification"
    payload: ChatNotificationPayload
Enter fullscreen mode Exit fullscreen mode

Keep the Literal on action. It becomes "const" in the JSON schema, which lets the code generator later turn "some object" into a union the TypeScript compiler can narrow on. The docstrings travel too, into the docs page and as comments in the generated clients.

2. One handler per message

Add the consumer below the models:

@channel(name="room_chat", description="Chat in a named room")
class RoomChatConsumer(AsyncJsonWebsocketConsumer):
    channel_layer_alias = "default"

    async def post_authentication(self) -> None:
        self.room = self.scope["path_params"]["room_name"]
        await self.join_group(f"room_{self.room}")

    @ws_handler
    async def handle_ping(self, _message: PingMessage) -> PongMessage:
        return PongMessage()

    @ws_handler(output_type=ChatNotification)
    async def handle_chat(self, message: ChatMessage) -> None:
        await self.broadcast_message(
            ChatNotification(
                payload=ChatNotificationPayload(room=self.room, text=message.payload.text)
            )
        )
Enter fullscreen mode Exit fullscreen mode

For every incoming message, chanx picks the handler whose argument type has a matching action, so you never write a receive method or an if action == ... chain.

handle_ping returns its reply, which goes back to the sender only. handle_chat broadcasts to the room's group instead, and because its return type is None, output_type tells chanx (and the docs) what it sends. join_group adds the connection to a group named after the room in the URL, and the connection leaves that group on its own when it disconnects.

3. Wire it into FastAPI

At the bottom of main.py:

register_channel_layer("default", InMemoryChannelLayer())  # Redis in production

app = FastAPI()
app.router.add_websocket_route("/ws/room/{room_name}", RoomChatConsumer.as_asgi())


@app.get("/asyncapi")
async def docs(request: Request):
    return await asyncapi_docs(request=request, app=app)


@app.get("/asyncapi.json")
async def spec(request: Request):
    return await asyncapi_spec_json(request=request, app=app)
Enter fullscreen mode Exit fullscreen mode

The channel layer is what carries broadcasts between connections. In-memory is fine while everything runs in one process; section 9 swaps it for Redis.

Run it:

uvicorn main:app
Enter fullscreen mode Exit fullscreen mode

4. Try it

Here's a quick script with two users in the same room. Save it as try_it.py:

import asyncio
import json

import websockets


async def main():
    url = "ws://localhost:8000/ws/room/lobby"
    async with websockets.connect(url) as alice, websockets.connect(url) as bob:
        await alice.send(json.dumps({"action": "ping"}))
        print("alice <-", await alice.recv())

        await alice.send(json.dumps({"action": "chat", "payload": {"text": "hi bob"}}))
        print("alice <-", await alice.recv())
        print("bob   <-", await bob.recv())

        await alice.send(json.dumps({"action": "chat", "payload": {"txt": "hi"}}))
        print("alice <-", await alice.recv())


asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode
$ python try_it.py
alice <- {"action": "pong", "payload": null}
alice <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
bob   <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
alice <- {"action": "error", "payload": [{"type": "missing", "loc": ["chat", "payload", "text"], "msg": "Field required"}]}
Enter fullscreen mode Exit fullscreen mode

Look at the last line: txt instead of text never reached handle_chat. Pydantic rejected it first, and the sender got an error naming the missing field.

5. The docs page

Open http://localhost:8000/asyncapi.

The generated AsyncAPI docs page

This is the official AsyncAPI viewer reading /asyncapi.json, which chanx builds from the handlers: the channel and its room_name parameter, each operation, and each message's schema and docstring. Since it comes from the code on every request, it can't drift out of date the way a wiki page does.

6. A typed TypeScript client

The same spec feeds a client generator. In a frontend project (or an empty folder):

npm install @chanx-js/client
npm install -D @chanx-js/codegen typescript
npx chanx-codegen http://localhost:8000/asyncapi.json -o src/generated
Enter fullscreen mode Exit fullscreen mode

The generator reads the spec and writes plain TypeScript. Part of src/generated/schemas.ts:

/** A message a user posts to the room. */
export interface ChatMessage {
  action: "chat";
  payload: ChatPayload;
}

/** A message broadcast to everyone in the room. */
export interface ChatNotification {
  action: "chat_notification";
  payload: ChatNotificationPayload;
}
Enter fullscreen mode Exit fullscreen mode

And src/generated/channels.ts knows which messages go in which direction, and which reply each request gets:

export type RoomChatToServer = ChatMessage | PingMessage;
export type RoomChatToClient = ChatNotification | PongMessage;
export type RoomChatReplies = {
  chat: ChatNotification;
  ping: PongMessage;
};

/** Chat in a named room */
export const roomChat = defineChannel<RoomChatToServer, RoomChatToClient, RoomChatReplies>()({
  name: "room_chat",
  address: "/ws/room/{room_name}",
  heartbeat: true,
});
Enter fullscreen mode Exit fullscreen mode

Using it (src/chat.ts):

import { createClient } from '@chanx-js/client';
import { roomChat } from './generated/index.js';

const client = createClient({ baseUrl: 'ws://localhost:8000' });
const room = client.connect(roomChat, { params: { room_name: 'lobby' } });

room.on('chat_notification', (message) => {
  console.log(`[${message.payload.room}] ${message.payload.text}`);
});

const pong = await room.request({ action: 'ping' });
console.log('server replied with', pong.action);

room.send({ action: 'chat', payload: { text: 'hello from TypeScript' } });
Enter fullscreen mode Exit fullscreen mode
$ npx tsx src/chat.ts
server replied with pong
[lobby] hello from TypeScript
Enter fullscreen mode Exit fullscreen mode

message in the on callback is already a ChatNotification, and request({ action: 'ping' }) resolves to a PongMessage, both inferred from the spec. In React, Vue, Svelte or Solid you'd use the matching hook from @chanx-js/client/react (and so on) instead of createClient.

7. Mistakes become compile errors

Now some common mistakes:

client.connect(roomChat, { params: { room: 'lobby' } });
room.send({ action: 'chatt', payload: { text: 'hi' } });
room.send({ action: 'chat', payload: { txt: 'hi' } });
room.send({ action: 'chat_notification', payload: { room: 'lobby', text: 'hi' } });
Enter fullscreen mode Exit fullscreen mode
$ npx tsc --noEmit
error TS2353: Object literal may only specify known properties, and 'room' does not exist in type '{ room_name: string | number; }'.
error TS2820: Type '"chatt"' is not assignable to type '"chat" | "ping"'. Did you mean '"chat"'?
error TS2561: Object literal may only specify known properties, but 'txt' does not exist in type 'ChatPayload'. Did you mean to write 'text'?
error TS2322: Type '"chat_notification"' is not assignable to type '"chat" | "ping"'.
Enter fullscreen mode Exit fullscreen mode

The last one is easy to miss: chat_notification is a message the server sends, and the spec says so, so the client isn't allowed to send it.

8. The rename

Back to the error from the top of the post. On the server, rename text to body in ChatNotificationPayload (and in handle_chat), restart, and regenerate:

npx chanx-codegen http://localhost:8000/asyncapi.json -o src/generated
npx tsc --noEmit
Enter fullscreen mode Exit fullscreen mode
src/chat.ts(8,60): error TS2339: Property 'text' does not exist on type 'ChatNotificationPayload'.
Enter fullscreen mode Exit fullscreen mode

The build fails on the line that reads the old field. Without the generated client, that same line would compile and quietly render nothing.

In my projects I commit the generated folder and run the generator in CI against the backend's spec. A backend PR that renames a field then shows the client diff in the same PR, and the frontend type check fails before anything merges.

9. Scale it out with Redis

So far everything runs in one process. In production you'd run several servers behind a load balancer, and two people in the same room can easily land on different ones. Before testing that, one more feature real apps need.

Messages don't only come from other WebSocket clients. A notification usually starts in an HTTP view or a background job, and for that chanx sends events through the channel layer. Add one line to the consumer so it forwards ChatNotification events straight to its client:

class RoomChatConsumer(AsyncJsonWebsocketConsumer):
    channel_layer_alias = "default"
    passthrough_events = [ChatNotification]
    ...
Enter fullscreen mode Exit fullscreen mode

and an ordinary FastAPI endpoint that broadcasts one into a room:

class Announcement(BaseModel):
    text: str


@app.post("/rooms/{room_name}/announce")
async def announce(room_name: str, announcement: Announcement):
    await RoomChatConsumer.broadcast_event(
        ChatNotification(payload=ChatNotificationPayload(room=room_name, text=announcement.text)),
        groups=[f"room_{room_name}"],
    )
    return {"sent": True}
Enter fullscreen mode Exit fullscreen mode

The event is the same Pydantic model the clients already know, so it's typed end to end, and the spec picks it up as a send operation.

Now start two copies of the app, still on the in-memory layer:

pip install httpx
uvicorn main:app --port 8001   # server A
uvicorn main:app --port 8002   # server B
Enter fullscreen mode Exit fullscreen mode

Connect Alice to A and Bob to B, both in lobby, then post an announcement to B (save as two_servers.py):

import asyncio
import json

import httpx
import websockets


async def recv(ws):
    try:
        return await asyncio.wait_for(ws.recv(), timeout=2)
    except TimeoutError:
        return "(nothing)"


async def main():
    alice_url = "ws://localhost:8001/ws/room/lobby"  # server A
    bob_url = "ws://localhost:8002/ws/room/lobby"  # server B
    async with websockets.connect(alice_url) as alice, websockets.connect(bob_url) as bob:
        await alice.send(json.dumps({"action": "chat", "payload": {"text": "hi bob"}}))
        print("alice <-", await recv(alice))
        print("bob   <-", await recv(bob))

        async with httpx.AsyncClient() as http:
            await http.post("http://localhost:8002/rooms/lobby/announce", json={"text": "deploy at 5pm"})
        print("alice <-", await recv(alice))
        print("bob   <-", await recv(bob))


asyncio.run(main())
Enter fullscreen mode Exit fullscreen mode
$ python two_servers.py
alice <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
bob   <- (nothing)
alice <- (nothing)
bob   <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "deploy at 5pm"}}
Enter fullscreen mode Exit fullscreen mode

Each server only knows about its own connections, so Bob never sees Alice's message and Alice never sees the announcement. The fix is a channel layer every server shares. Start Redis and install the Redis extra:

docker run -d -p 6379:6379 redis:7-alpine
pip install "fast-channels[redis]"
Enter fullscreen mode Exit fullscreen mode

Then change the layer setup in main.py. The consumer, the handlers and the endpoint stay as they are:

import os

from fast_channels.layers import register_channel_layer
from fast_channels.layers.redis import RedisChannelLayer

redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379")
register_channel_layer("default", RedisChannelLayer(hosts=[redis_url]))
Enter fullscreen mode Exit fullscreen mode

Restart both servers and run the script again:

$ python two_servers.py
alice <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
bob   <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "hi bob"}}
alice <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "deploy at 5pm"}}
bob   <- {"action": "chat_notification", "payload": {"room": "lobby", "text": "deploy at 5pm"}}
Enter fullscreen mode Exit fullscreen mode

Both servers now deliver both messages, including the announcement that was posted to server B only. A third or tenth server works the same way. The same broadcast_event call also works from an async background worker, and sync code such as a Celery task can use broadcast_event_sync. If you regenerate the TypeScript client from section 6, it still compiles.

10. Bonus: a Python client for another service

The same spec works for a Python service talking to this one (in my case it's usually an AI agent service talking to the backend):

pip install "chanx[cli,client]"
chanx generate-client --schema http://localhost:8000/asyncapi.json --output ./chat_client
Enter fullscreen mode Exit fullscreen mode
import asyncio

from chat_client.room_chat import RoomChatClient
from chat_client.room_chat.messages import ChatMessage, ChatNotification, ChatPayload, PongMessage


class Bot(RoomChatClient):
    async def send_init_message(self) -> None:
        await self.send_message(ChatMessage(payload=ChatPayload(text="hello from a Python service")))

    async def handle_message(self, message: ChatNotification | PongMessage) -> None:
        match message:
            case ChatNotification():
                print(f"[{message.payload.room}] {message.payload.text}")
                await self.disconnect()
            case PongMessage():
                pass


if __name__ == "__main__":
    asyncio.run(Bot("ws://localhost:8000", path_params={"room_name": "lobby"}).handle())
Enter fullscreen mode Exit fullscreen mode
$ python bot.py
[lobby] hello from a Python service
Enter fullscreen mode Exit fullscreen mode

The generated messages are Pydantic models, so mypy and pyright check this side too.

Where to go from here

  • The same consumer runs on Django Channels: import it from chanx.channels.websocket instead and route it with Channels. Django quick start
  • Auth, testing helpers, events pushed from HTTP views or background jobs: chanx docs
  • TypeScript client, hooks for each framework, and a Node server that speaks the same protocol: chanx-js
  • How Python handlers map to AsyncAPI 3 channels, operations and replies, in detail: my AsyncAPI Conf 2026 talk

Code: github.com/chanx-dev/chanx (BSD).

Hope it saves you the day it cost me. If you keep WebSocket messages in sync some other way, share your tips in the comments; I'm always happy to pick up a better trick.

Top comments (0)