Real-Time Chat API with WebSockets

Build a multi-room chat API that scales horizontally with Redis Pub/Sub.

Introduction

30 Second Summary

Every time you send a message in Slack or Discord, it appears on everyone else's screen instantly. No page refresh, no polling, no delay.

In this project, you will build a multi-room chat API using FastAPI and WebSockets. You'll connect multiple browser tabs to the same room, exchange messages in real time, hit a real scaling wall, and fix it with Redis Pub/Sub.

What You'll Build

You'll open several browser tabs, pick usernames, join a chat room, and watch messages flow between all of them instantly with zero page refreshes.

By the end of this project, you'll have:

  • A real-time multi-room chat server where you open browser tabs, pick a username, join a room, and see messages appear across all connected tabs the instant they're sent.
  • A scaling wall you hit firsthand when running multiple server processes. Messages stop flowing between workers because each process holds its own connections in memory.
  • A Redis Pub/Sub fix that bridges your server processes so chat works across any number of workers, the same pattern used in production systems like Slack and Discord.
  • Secret Mission: Add message history so new users joining a room see the last 20 messages they missed.

Are there any prerequisites?

You'll need basic Python knowledge and some familiarity with HTTP APIs. No prior WebSocket experience is required.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

This project is all about real-time communication. Regular HTTP forces a client to ask the server for updates over and over. WebSockets flip that model by keeping a persistent two-way connection open, so the server can push messages the instant they arrive.

Set Up Your Project

For a real-time chat server, you need a framework that can hold persistent connections open. Regular HTTP frameworks process a request and immediately close the connection. FastAPI supports WebSocket connections natively and runs on an ASGI server designed to handle these long-lived connections.

In this step, you'll create a project folder, set up a Python virtual environment, install FastAPI, and verify everything runs by visiting the auto-generated API docs in your browser.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install FastAPI and run a minimal server.
  • Set up static file serving for the chat client.
Create your project folder and install FastAPI

This project requires Python 3.10 or higher. Start by confirming your version.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal (macOS) or PowerShell (Windows), and pressing Enter.
  • Check your Python version by running:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.10 or higher

You're all set. Move on to the next instruction below.

ⓧ I see an older version

You need Python 3.10+ for this project. On macOS, the system Python is often outdated.

  • Download and install the latest Python from python.org/downloads.
  • After installing, close and reopen your terminal.
  • Run the version check again to confirm it now shows 3.10 or higher.

ⓧ Command not found

Python is not installed on your system.

  • Download and install Python 3.10+ from python.org/downloads.
  • On Windows, check the box that says Add Python to PATH during installation.
  • Close and reopen your terminal, then run the version check again.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir realtime-chat
cd realtime-chat
  • Confirm the folder exists by running:
ls

The output is empty because the folder is brand new. That's expected.

  • Create a Python virtual environment by running:
python -m venv .venv
  • Activate the virtual environment by running:

macOS

source .venv/bin/activate

Windows

.venv\Scripts\activate

Your terminal prompt now shows (.venv) at the start. This confirms the virtual environment is active.

Don't see (.venv) in your prompt?

  • On Windows, if you get a permissions error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activate command again.
  • Make sure you're inside the realtime-chat folder before activating.

Why isn't my virtual environment activating?

  • Install FastAPI with all its standard dependencies by running:
pip install "fastapi[standard]==0.141.1"

You'll see download and installation progress in your terminal. The final line confirms successful installation of fastapi and its dependencies.

What does fastapi[standard] include?

The [standard] extras bundle includes Uvicorn (the ASGI server that handles WebSocket connections) and other utilities like auto-reload for development. Without it, you'd have to install the server separately.

Installation failed?

  • Make sure you see (.venv) in your prompt. If not, activate the virtual environment first.
  • If you get a permission error, you may be installing into the system Python instead of the virtual environment.

Help me fix my FastAPI installation error.

  • Create a requirements.txt file to track your dependency. Open VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the realtime-chat folder on your Desktop, then click Open.
  • Create a new file by pressing Cmd+N (macOS) or Ctrl+N (Windows).
  • Paste the following code into the new file:
fastapi[standard]==0.141.1
  • Save the file as requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows) and entering requirements.txt as the filename.
  • Create another new file (Cmd+N or Ctrl+N) and paste the following code:
from fastapi import FastAPI

# Create the FastAPI application instance
app = FastAPI()

@app.get("/")
async def root():
    # Health check endpoint
    return {"status": "ok"}
  • Save the file as main.py by pressing Cmd+S (macOS) or Ctrl+S (Windows) and entering main.py as the filename.

What does this code do?

  • app = FastAPI() creates your application instance. FastAPI uses this object to register all your routes.
  • @app.get("/") defines a GET endpoint at the root path. It returns a simple JSON response confirming the server is alive.

Before you run the server, what do you think you'll see if you visit http://127.0.0.1:8000/docs in your browser?

  • Switch back to your terminal (make sure you're still in the realtime-chat folder with (.venv) active).
  • Start the development server by running:
fastapi dev main.py

Your terminal shows Uvicorn running on http://127.0.0.1:8000 and the server is now running with auto-reload enabled.

  • Open your browser and go to http://127.0.0.1:8000/docs.

You see the FastAPI auto-generated Swagger UI with your GET / endpoint listed. FastAPI builds this interactive documentation automatically from your route definitions.

Page won't load?

  • Check that your terminal still shows the server running. If it crashed, look at the error message for a syntax issue in main.py.
  • Make sure you're visiting http://127.0.0.1:8000/docs (not https, and not a different port).

My FastAPI dev server won't start or the docs page won't load.

Set up static file serving

Your chat app needs a browser-based client. FastAPI can serve HTML files directly using its StaticFiles middleware, so you don't need a separate web server.

  • Open a new terminal tab or window (the dev server is using your current terminal).
  • Navigate to your project folder and create the static directory by running:
cd ~/Desktop/realtime-chat
mkdir static
  • Confirm the directory was created by running:
ls

You should see main.py, requirements.txt, .venv, and static in the output.

  • In VS Code, create a new file inside the static folder by right-clicking static in the Explorer sidebar and selecting New File.
  • Name the file index.html and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Chat</title></head>
<body><h1>Coming soon</h1></body>
</html>
  • Save static/index.html.

What does this file do?

This is a placeholder. You'll replace it with a full chat interface in the next step. For now it proves that static file serving works.

  • Now update main.py to mount the static directory. Open main.py in VS Code and replace all the code with:
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

# Create the FastAPI application instance
app = FastAPI()

# Serve files from the static/ directory at the /static URL path
app.mount("/static", StaticFiles(directory="static"), name="static")

@app.get("/")
async def root():
    # Health check endpoint
    return {"status": "ok"}
  • Save main.py.

What does app.mount do?

  • app.mount("/static", StaticFiles(directory="static"), name="static") tells FastAPI to serve any file inside your static/ folder when a browser requests a URL starting with /static/.
  • The dev server auto-reloads when you save, so the change is already live.
  • Open your browser and go to http://127.0.0.1:8000/static/index.html.

You see a page displaying "Coming soon" in large text. Your static file serving is working.

Seeing a 404 Not Found?

  • Make sure the file is at static/index.html (inside the static folder, not at the project root).
  • Check that main.py has the app.mount line and that you saved the file.
  • Confirm the URL is /static/index.html (not /index.html).

Help me fix my static file 404 error in FastAPI.

✔️ Awesome, I've got everything!

Great. Double check you've saved both main.py and static/index.html.

ⓧ I'd like to double check the full code

Here is the full main.py at this point:

from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

@app.get("/")
async def root():
    return {"status": "ok"}

Here is requirements.txt:

fastapi[standard]==0.141.1

Here is static/index.html:

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Chat</title></head>
<body><h1>Coming soon</h1></body>
</html>

Your project is ready to go. You have a running FastAPI server with static file serving and auto-generated API docs. Next up, you'll add your first WebSocket endpoint and see real-time two-way communication in action.

Build Your First WebSocket

Right now, your FastAPI server uses HTTP. Every interaction follows the same pattern: the browser asks a question, the server answers, and the connection closes. The server has no way to reach out to the browser on its own.

WebSockets solve this by keeping a persistent two-way connection open between the browser and the server. In this step, you'll add a WebSocket endpoint that echoes messages back instantly, proving the connection stays alive.

In this step, get ready to:
  • Add a WebSocket endpoint that keeps a connection open and echoes back any text it receives.
  • Build an HTML and JavaScript client that connects to the WebSocket and displays responses.
  • Verify real-time two-way communication in your browser with no page refreshes.
Add a WebSocket echo endpoint

A WebSocket endpoint works differently from a regular HTTP route. Instead of returning a response and closing, it accepts the connection and then loops, waiting for messages from the client and sending replies back over the same open connection.

  • In main.py, find the import line at the top of the file:
from fastapi import FastAPI
  • Update it to include WebSocket and WebSocketDisconnect, so the line looks like this:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
  • Add the following WebSocket endpoint below your root() function in main.py:
@app.websocket("/ws")
async def websocket_echo(websocket: WebSocket):
    # Accept the incoming connection
    await websocket.accept()
    try:
        # Keep receiving messages until the client disconnects
        while True:
            text = await websocket.receive_text()
            await websocket.send_text(f"Echo: {text}")
    except WebSocketDisconnect:
        # Client closed the connection
        pass

What does this code do?

This endpoint behaves completely differently from a normal HTTP route:

  • await websocket.accept() upgrades the HTTP connection to a WebSocket. The connection stays open from this point forward.
  • The while True loop keeps the server listening for messages indefinitely. Each time the client sends text, the server echoes it back with a prefix.
  • When the client closes their browser tab, WebSocketDisconnect is raised and the server exits the loop cleanly.
  • Save main.py.
  • Check your terminal where the dev server is running. You should see a reload message confirming the server detected your changes.

Seeing an error in the terminal?

  • Make sure WebSocket and WebSocketDisconnect are both imported from fastapi on the first line. A missing import causes an immediate error on reload.
  • Check that the new endpoint is placed after the root() function with a blank line separating them.

Why is my WebSocket endpoint causing a reload error?

Build the HTML/JS test client

You need a client that can open a WebSocket connection and send messages through it. A simple HTML page with a few lines of JavaScript is all it takes.

  • Replace the entire contents of static/index.html with the following:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket Echo</title>
</head>
<body>
    <h1>WebSocket Echo</h1>
    <input type="text" id="messageInput" placeholder="Type a message...">
    <button onclick="sendMessage()">Send</button>
    <!-- Messages from the server appear here -->
    <div id="messages"></div>
    <script>
        // Open a persistent WebSocket connection
        const ws = new WebSocket(`ws://${window.location.host}/ws`);
        // When a message arrives from the server, display it
        ws.onmessage = (event) => {
            const div = document.createElement('div');
            div.textContent = event.data;
            document.getElementById('messages').appendChild(div);
        };
        // Send the input text over the WebSocket
        function sendMessage() {
            const input = document.getElementById('messageInput');
            ws.send(input.value);
            input.value = '';
        }
    </script>
</body>
</html>

What does this client do?

  • new WebSocket(...) opens a connection the moment the page loads. It stays open until you close the tab.
  • ws.onmessage fires every time the server sends something. It creates a new <div> and appends it to the page.
  • sendMessage() grabs the input value and pushes it to the server over the same open connection.
  • Save static/index.html.

Not sure you got everything?

Make sure you replaced the entire file contents, including the <!DOCTYPE html> line. If any of the old placeholder HTML remains, the client will not work.

Help me check my index.html is correct for the WebSocket echo client.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on to the test.

ⓧ I'd like to double check the full code

Here is the complete main.py at this point:

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.staticfiles import StaticFiles

app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")

@app.get("/")
async def root():
    return {"status": "ok"}

@app.websocket("/ws")
async def websocket_echo(websocket: WebSocket):
    await websocket.accept()
    try:
        while True:
            text = await websocket.receive_text()
            await websocket.send_text(f"Echo: {text}")
    except WebSocketDisconnect:
        pass

And here is the complete static/index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket Echo</title>
</head>
<body>
    <h1>WebSocket Echo</h1>
    <input type="text" id="messageInput" placeholder="Type a message...">
    <button onclick="sendMessage()">Send</button>
    <div id="messages"></div>
    <script>
        const ws = new WebSocket(`ws://${window.location.host}/ws`);
        ws.onmessage = (event) => {
            const div = document.createElement('div');
            div.textContent = event.data;
            document.getElementById('messages').appendChild(div);
        };
        function sendMessage() {
            const input = document.getElementById('messageInput');
            ws.send(input.value);
            input.value = '';
        }
    </script>
</body>
</html>
Test the echo in your browser

Both files are saved and the dev server has reloaded. Before you test, think about what you expect to happen when you type a message and click Send. Will the page refresh? Will there be a delay?

  • Open http://127.0.0.1:8000/static/index.html in your browser.
  • Type any message into the input field and click Send.

You see your message echoed back instantly with an "Echo:" prefix. The page did not refresh. The connection stayed open the entire time.

  • Send a few more messages. Each one appears below the previous, all without a page reload.
  • Open your browser's DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
  • Click the Network tab.
  • Filter by WS (WebSocket). You should see a single ws entry that stays open with a status of 101.

Why 101 and not 200?

HTTP status 101 means "Switching Protocols." The browser started with an HTTP request, but the server upgraded it to a WebSocket. From that point on, both sides can send messages freely without new requests.

Not seeing echoed messages?

  • Make sure your dev server is still running in the terminal. If it crashed, restart it with fastapi dev main.py.
  • Check the browser console (in DevTools) for connection errors. A message like "WebSocket connection failed" means the server endpoint path doesn't match the client URL.
  • Confirm you're visiting http://127.0.0.1:8000/static/index.html and not opening the file directly from your file system.

Help me debug why my WebSocket echo is not working.

Your echo server proves that WebSocket connections stay alive and messages flow both ways instantly. Next up, you'll turn this one-to-one echo into a multi-room chat where messages broadcast to everyone in the same room.

Add Multi-Room Broadcasting

Your echo server talks back to the one client that sent the message. That is fine for testing, but useless for a chat room.

A real chat needs to take a message from one user and deliver it to everyone else in the same room instantly. The pattern that handles this is called a ConnectionManager. It tracks which connections belong to which room, and broadcasts incoming messages to all of them.

In this step, get ready to:
  • Build a ConnectionManager class that organises connections by room.
  • Replace the echo endpoint with a room-based broadcasting endpoint.
  • Update the HTML client so multiple users can join rooms and chat.
Create the ConnectionManager class

The ConnectionManager is a single object that lives in memory and keeps track of every open WebSocket connection, grouped by room name. When a message arrives, it loops through every connection in that room and pushes the message out.

  • Create a new file called manager.py in your realtime-chat folder.
  • Add the class skeleton with the connect and disconnect methods by pasting this code:
from fastapi import WebSocket


class ConnectionManager:
    def __init__(self):
        # Maps room names to lists of active WebSocket connections
        self.rooms: dict[str, list[WebSocket]] = {}

    async def connect(self, websocket: WebSocket, room: str):
        # Accept the connection and add it to the room
        await websocket.accept()
        if room not in self.rooms:
            self.rooms[room] = []
        self.rooms[room].append(websocket)

    def disconnect(self, websocket: WebSocket, room: str):
        # Remove the connection and clean up empty rooms
        if room in self.rooms:
            if websocket in self.rooms[room]:
                self.rooms[room].remove(websocket)
            if not self.rooms[room]:
                del self.rooms[room]

What does this code do?

The rooms dictionary is the heart of the manager. Each key is a room name (like general) and each value is a list of live WebSocket connections currently in that room.

  • connect() accepts the WebSocket handshake and registers the connection under the given room name.
  • disconnect() removes a connection from its room. If the room is now empty, it deletes the room entry entirely to keep memory clean.
  • Below the disconnect method, add the broadcast method and the singleton instance:
    async def broadcast(self, room: str, message: dict):
        # Send a message to all connections in a room
        if room not in self.rooms:
            return
        dead = []
        for connection in self.rooms[room]:
            try:
                await connection.send_json(message)
            except Exception:
                dead.append(connection)
        # Remove any connections that failed to send
        for conn in dead:
            if conn in self.rooms.get(room, []):
                self.rooms[room].remove(conn)

    def get_room_stats(self) -> dict[str, int]:
        # Return a dictionary of room names to connected user counts
        return {room: len(clients) for room, clients in self.rooms.items() if clients}


manager = ConnectionManager()

What does this code do?

  • broadcast() loops through every connection in a room and sends the message as JSON. If a send fails (the client disconnected ungracefully), it collects that connection in a dead list and prunes it afterwards.
  • get_room_stats() returns a quick summary of how many users are in each room. You will expose this through a REST endpoint shortly.
  • The last line creates a single shared manager instance that the rest of your app will import.
  • Save manager.py.
Update main.py to use the ConnectionManager

The old echo endpoint only talked to one client. Now you will replace it with a new endpoint that accepts a room name in the URL path and a username as a query parameter, then broadcasts every message to the whole room.

  • Open main.py and replace its entire contents with this imports and app setup section:
from datetime import datetime, timezone

from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
from fastapi.staticfiles import StaticFiles

from manager import manager


app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")


@app.get("/")
async def root():
    return {"status": "ok"}


@app.get("/rooms")
async def list_rooms():
    # Return the number of connected users in each active room
    return manager.get_room_stats()

What changed from before?

  • You now import Query (for WebSocket query params), datetime (for timestamps), and the manager singleton from your new file.
  • The /rooms endpoint exposes live room statistics as a normal GET request. This combines REST and WebSocket APIs in the same app.
  • Below the list_rooms function, add the new WebSocket endpoint:
@app.websocket("/ws/{room}")
async def websocket_endpoint(
    websocket: WebSocket,
    room: str,
    username: str = Query(default="Anonymous"),
):
    await manager.connect(websocket, room)
    # Notify the room that a user joined
    join_message = {
        "type": "system",
        "message": f"{username} joined the room",
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }
    await manager.broadcast(room, join_message)
    try:
        while True:
            text = await websocket.receive_text()
            # Broadcast the message to all room members
            chat_message = {
                "type": "message",
                "username": username,
                "message": text,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
            await manager.broadcast(room, chat_message)
    except WebSocketDisconnect:
        manager.disconnect(websocket, room)
        leave_message = {
            "type": "system",
            "message": f"{username} left the room",
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        await manager.broadcast(room, leave_message)

How does the room endpoint work?

The URL path /ws/{room} captures the room name directly from the URL. The username arrives as a query parameter, defaulting to Anonymous if omitted.

  • On connect, the endpoint broadcasts a system message telling the room someone joined.
  • Inside the while True loop, every incoming text message gets wrapped in a JSON dict (with username and timestamp) and broadcast to the entire room.
  • When WebSocketDisconnect fires, the connection is removed and a "left" message goes out.
  • Save main.py. The dev server auto-reloads.
  • Open http://127.0.0.1:8000/rooms in your browser. You should see an empty JSON object: {}.

Seeing an import error?

  • Check that manager.py is in the same folder as main.py (not inside static/).
  • Confirm you saved manager.py before main.py. The dev server tries to import the module on reload.
  • Make sure the last line in manager.py is manager = ConnectionManager() with no indentation.

Why can't my main.py find the manager module?

Build the multi-room chat client

The HTML client needs a username field, a room field, and logic to connect to the room-specific WebSocket URL. It also needs to parse JSON messages and display system notifications differently from chat messages.

  • Open static/index.html and delete everything. Replace it with this head section containing the page styles:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Real-Time Chat</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
        h1 { margin-bottom: 20px; }
        .setup { margin-bottom: 20px; }
        .setup input { padding: 8px; margin-right: 8px; border: 1px solid #ccc; border-radius: 4px; }
        .setup button { padding: 8px 16px; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; }
        #messages { border: 1px solid #e5e7eb; border-radius: 8px; height: 400px; overflow-y: auto; padding: 16px; margin-bottom: 16px; background: #f9fafb; }
        .msg { margin-bottom: 8px; padding: 8px; border-radius: 4px; }
        .msg.system { color: #6b7280; font-style: italic; background: #f3f4f6; }
        .msg.chat { background: white; border: 1px solid #e5e7eb; }
        .msg .sender { font-weight: 600; color: #2563eb; }
        .msg .time { font-size: 0.75rem; color: #9ca3af; margin-left: 8px; }
        .send-area { display: flex; gap: 8px; }
        .send-area input { flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
        .send-area button { padding: 8px 16px; background: #2563eb; color: white; border: none; border-radius: 4px; cursor: pointer; }
        #status { margin-top: 12px; font-size: 0.875rem; color: #6b7280; }
    </style>
</head>
  • Below the </head> tag, add the page body with input fields and the message display area:
<body>
    <h1>Real-Time Chat</h1>
    <div class="setup">
        <input type="text" id="username" placeholder="Username" value="User1">
        <input type="text" id="room" placeholder="Room name" value="general">
        <button onclick="connect()">Join Room</button>
    </div>
    <div id="messages"></div>
    <div class="send-area">
        <input type="text" id="messageInput" placeholder="Type a message..." onkeypress="if(event.key==='Enter')sendMessage()">
        <button onclick="sendMessage()">Send</button>
    </div>
    <div id="status">Not connected</div>
  • Below the status div, add the JavaScript that handles the WebSocket connection:
    <script>
        let ws = null;
        function connect() {
            const username = document.getElementById('username').value || 'Anonymous';
            const room = document.getElementById('room').value || 'general';
            if (ws) { ws.close(); }
            ws = new WebSocket(`ws://${window.location.host}/ws/${room}?username=${username}`);
            ws.onopen = () => {
                document.getElementById('status').textContent = `Connected to room: ${room}`;
                document.getElementById('messages').innerHTML = '';
            };
            ws.onmessage = (event) => {
                const data = JSON.parse(event.data);
                const messagesDiv = document.getElementById('messages');
                const msgDiv = document.createElement('div');
                if (data.type === 'system') {
                    msgDiv.className = 'msg system';
                    msgDiv.textContent = data.message;
                } else {
                    msgDiv.className = 'msg chat';
                    const time = new Date(data.timestamp).toLocaleTimeString();
                    msgDiv.innerHTML = `<span class="sender">${data.username}</span><span class="time">${time}</span><br>${data.message}`;
                }
                messagesDiv.appendChild(msgDiv);
                messagesDiv.scrollTop = messagesDiv.scrollHeight;
            };
            ws.onclose = () => {
                document.getElementById('status').textContent = 'Disconnected';
            };
        }
  • Still inside the <script> tag, add the send helper and close the file:
        function sendMessage() {
            const input = document.getElementById('messageInput');
            if (ws && input.value) {
                ws.send(input.value);
                input.value = '';
            }
        }
    </script>
</body>
</html>

How does the client work now?

  • connect() reads the username and room inputs, then opens a WebSocket to /ws/{room}?username={name}.
  • onmessage parses the incoming JSON. System messages (joins and leaves) get italic grey styling. Chat messages show the sender name in blue and a timestamp.
  • sendMessage() transmits the text input over the WebSocket. The server will broadcast it back to the room, so you see your own message appear just like everyone else does.
  • Save static/index.html.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before testing.

ⓧ I'd like to double check the full code

Since each file is built across multiple chunks, scroll up and confirm you have each section in order:

  • manager.py: The class skeleton (connect/disconnect) followed by the broadcast/stats chunk ending with manager = ConnectionManager().
  • main.py: Imports and app setup (with /rooms endpoint) followed by the /ws/{room} WebSocket endpoint.
  • static/index.html: Head with CSS, then body HTML structure, then the connect() function, then sendMessage() with closing tags.

Before you test, think about this: if you open three browser tabs and send a message from one, what do you expect to see in the other two?

  • Open http://127.0.0.1:8000/static/index.html in your browser.
  • Leave the username as User1 and room as general. Click Join Room.
  • Open two more browser tabs to the same URL.
  • In the second tab, change the username to User2 and click Join Room.
  • In the third tab, change the username to User3 and click Join Room.
  • Type a message in any tab and click Send.

All three tabs display the message instantly. You also see italic system notifications like "User2 joined the room" in each tab.

  • Open http://127.0.0.1:8000/rooms in any tab. You should see {"general": 3} confirming three connections in the room.

Messages not appearing in other tabs?

  • Make sure each tab clicked Join Room. Just opening the page does not connect automatically.
  • Check the status line at the bottom of the page. It should say "Connected to room: general". If it says "Disconnected", the WebSocket failed to connect.
  • Open DevTools (F12) and check the Console tab for errors. A 404 means the endpoint URL does not match. Confirm your main.py has /ws/{room} (not /ws).

Why are my browser tabs not receiving messages from each other?

You now have a working multi-room chat server. Next up, you will break it on purpose by running two server processes and discover why in-memory state cannot scale.

Scale with Redis Pub/Sub

Your multi-room chat works beautifully with a single server process. Every tab connects to the same process, so the ConnectionManager in memory sees all connections and can broadcast to everyone.

But production apps run multiple server processes to handle load. What happens when two users connect to different processes? Each process has its own ConnectionManager with its own list of connections, and they have no way to talk to each other.

In this step, get ready to:
  • Hit the multi-process scaling wall firsthand.
  • Install Redis and wire it up as a message bus between server workers.
  • Verify that messages flow across multiple server processes.
Experience the scaling wall

Before fixing anything, you need to see the problem. Right now your dev server runs a single process. Let's switch to multiple workers and watch the chat break.

  • Stop your running dev server by pressing Ctrl+C in the terminal.
  • Start the server with 2 worker processes by running this command:
uvicorn main:app --workers 2 --port 8000
  • Open 3 browser tabs at http://127.0.0.1:8000/static/index.html.
  • In each tab, enter a different username and join the room general.

Before you send any messages, what do you expect to happen? Do you think all 3 tabs will see each other's messages, like they did in Step 3?

  • Send a message from each tab and watch what appears in the other tabs.

Some messages vanish. A message sent from one tab might not appear in another tab. That is because each worker process has its own ConnectionManager in memory, and workers cannot see each other's connections.

Why do messages disappear?

When you run --workers 2, Uvicorn spawns two separate Python processes. Each process has its own copy of the ConnectionManager singleton.

If Tab A connects to Worker 1 and Tab B connects to Worker 2, a message from Tab A is broadcast only to connections that Worker 1 knows about. Worker 2 never hears about it, so Tab B sees nothing.

  • Stop the multi-worker server by pressing Ctrl+C.

The fix is a shared message bus that sits outside any single process. When a message arrives at one worker, it publishes to the bus. All workers subscribe to the bus and relay messages to their local connections.

What is Pub/Sub?

Pub/Sub (publish/subscribe) is a messaging pattern where senders publish messages to a channel without knowing who is listening. Subscribers listen to channels and receive every message published there.

Redis implements Pub/Sub as a built-in feature. It is the same pattern used by production chat systems like Slack and Discord to bridge server processes.

Install Docker Desktop and start Redis

Redis is an in-memory data store that includes Pub/Sub. The easiest way to run it locally is inside a Docker container.

  • Check if Docker is already installed by running this command:
docker --version

✔️ I see a version number

Docker is ready. Make sure Docker Desktop is running (you should see the whale icon in your system tray or menu bar), then continue below.

ⓧ Command not found

You need to install Docker Desktop.

  • Go to docker.com/get-started and download Docker Desktop for your operating system.
  • Run the installer and follow the prompts. On Windows, accept the default WSL 2 backend option when asked.
  • Once the installer finishes, open Docker Desktop from your applications.
  • Wait for the Docker engine to start (the whale icon appears in your system tray or menu bar).
  • Re-run docker --version in your terminal to confirm it works.

Still seeing command not found?

Close and reopen your terminal after installing Docker Desktop. The docker command is only available in terminals opened after installation completes.

help me troubleshoot Docker Desktop installation

  • Start a Redis container by running this command:
docker run -d -p 6379:6379 --name redis redis:latest

What does this command do?

  • docker run creates and starts a new container.
  • -d runs it in the background (detached mode).
  • -p 6379:6379 maps the container's Redis port to your machine's port 6379.
  • --name redis gives the container a friendly name you can reference later.
  • redis:latest pulls the official Redis image from Docker Hub.
  • Verify the container is running by running:
docker ps

You should see a row showing a container named redis with port 6379 mapped and status Up.

Don't see the redis container?

If you see an error about the name already being in use, a previous container with that name exists. Remove it with docker rm -f redis then re-run the docker run command above.

help me get Redis running in Docker

  • Install the Python Redis client by running:
pip install redis==8.1.0
  • Update requirements.txt to include the new dependency. Open requirements.txt and add redis==8.1.0 on a new line so the full file looks like this:
fastapi[standard]==0.141.1
redis==8.1.0
  • Save requirements.txt.
Add Redis Pub/Sub to the WebSocket endpoint

Now you will rewrite main.py to publish every chat message to a Redis channel instead of broadcasting directly. A background listener on each worker subscribes to that channel and relays messages to local connections.

  • Select all the code in main.py and delete it. You will rebuild the file in stages.
  • Paste the following imports, globals, lifespan, and app setup:
import json
import asyncio
from datetime import datetime, timezone
from contextlib import asynccontextmanager

import redis.asyncio as aioredis
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
from fastapi.staticfiles import StaticFiles

from manager import manager


# Global Redis client and subscription tracking
redis_client: aioredis.Redis = None
subscriptions: dict[str, asyncio.Task] = {}


# Lifespan handles Redis connect on startup and cleanup on shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
    global redis_client
    redis_client = aioredis.from_url("redis://localhost:6379", decode_responses=True)
    yield
    for task in subscriptions.values():
        task.cancel()
    await redis_client.aclose()


app = FastAPI(lifespan=lifespan)
app.mount("/static", StaticFiles(directory="static"), name="static")

What does this code do?

  • redis_client is a module-level variable that holds the async Redis connection once the app starts.
  • subscriptions tracks background listener tasks by channel name so you don't create duplicates.
  • The lifespan context manager connects to Redis at startup and cancels all listeners and closes the connection on shutdown.
  • decode_responses=True tells the client to return strings instead of raw bytes.
  • Add the route handlers and Redis helper functions directly below the app setup:
@app.get("/")
async def root():
    return {"status": "ok"}


@app.get("/rooms")
async def list_rooms():
    return manager.get_room_stats()


# Subscribes to a Redis channel and broadcasts to local WebSocket clients
async def redis_listener(room: str):
    pubsub = redis_client.pubsub()
    await pubsub.subscribe(f"chat:{room}")
    try:
        async for message in pubsub.listen():
            if message["type"] == "message":
                data = json.loads(message["data"])
                await manager.broadcast(room, data)
    except asyncio.CancelledError:
        await pubsub.unsubscribe(f"chat:{room}")
        await pubsub.aclose()


# Creates a listener task if one isn't already running for this room
async def ensure_subscription(room: str):
    channel = f"chat:{room}"
    if channel not in subscriptions or subscriptions[channel].done():
        subscriptions[channel] = asyncio.create_task(redis_listener(room))


# Cancels the listener when no local connections remain in the room
def remove_subscription(room: str):
    channel = f"chat:{room}"
    if channel in subscriptions and not manager.rooms.get(room):
        subscriptions[channel].cancel()
        del subscriptions[channel]

What do the helper functions do?

  • redis_listener subscribes to a Redis channel (chat:general for room "general") and waits for messages. When one arrives, it deserializes the JSON and broadcasts to all local WebSocket connections via the ConnectionManager.
  • ensure_subscription checks whether a listener task already exists for the room's channel. If not, it spawns one as a background task.
  • remove_subscription cancels the listener task when the last local client disconnects from a room. This prevents idle tasks from piling up.
  • Save main.py and verify the server starts by running:
fastapi dev main.py

You should see the server start at http://127.0.0.1:8000 with no errors. The WebSocket endpoint is missing, so chat will not work yet, but the HTTP routes confirm your Redis connection and imports are correct.

  • Open http://127.0.0.1:8000 in your browser. You should see {"status":"ok"}.
  • Stop the server with Ctrl+C.

Seeing a ModuleNotFoundError for redis?

Make sure your virtual environment is active (you should see (.venv) in your terminal prompt). If it is not, activate it with .venv\Scripts\activate (Windows) or source .venv/bin/activate (macOS/Linux), then re-run pip install redis==8.1.0.

help me fix the redis import error

  • Now add the WebSocket endpoint at the bottom of main.py:
@app.websocket("/ws/{room}")
async def websocket_endpoint(
    websocket: WebSocket,
    room: str,
    username: str = Query(default="Anonymous"),
):
    await manager.connect(websocket, room)
    await ensure_subscription(room)

    # Publish join notification to Redis channel
    join_message = {
        "type": "system",
        "message": f"{username} joined the room",
        "timestamp": datetime.now(timezone.utc).isoformat(),
    }
    await redis_client.publish(f"chat:{room}", json.dumps(join_message))

    try:
        while True:
            text = await websocket.receive_text()
            # Publish chat message to Redis instead of broadcasting directly
            chat_message = {
                "type": "message",
                "username": username,
                "message": text,
                "timestamp": datetime.now(timezone.utc).isoformat(),
            }
            await redis_client.publish(f"chat:{room}", json.dumps(chat_message))
  • Add the disconnect handler directly below (still inside the function):
    except WebSocketDisconnect:
        manager.disconnect(websocket, room)
        # Publish leave notification to Redis channel
        leave_message = {
            "type": "system",
            "message": f"{username} left the room",
            "timestamp": datetime.now(timezone.utc).isoformat(),
        }
        await redis_client.publish(f"chat:{room}", json.dumps(leave_message))
        remove_subscription(room)

How does the new flow work?

Previously, the endpoint called manager.broadcast() directly. Now it calls redis_client.publish() to send the message to a Redis channel named chat:{room}.

The redis_listener running in the background on every worker picks up that message from Redis and calls manager.broadcast() locally. This means every worker relays the message to its own connected clients, regardless of which worker the sender was on.

  • Save main.py.

✔️ Awesome, I've got everything!

Double check you have saved main.py with all three sections (imports/lifespan, helpers, WebSocket endpoint) in order.

ⓧ I'd like to double check the full code

Your main.py should have these sections in order, matching what you pasted above:

  • Imports (json, asyncio, datetime, asynccontextmanager, redis.asyncio, FastAPI modules, manager).
  • Globals: redis_client and subscriptions.
  • The lifespan context manager.
  • app creation with lifespan=lifespan and static files mount.
  • GET / and GET /rooms handlers.
  • redis_listener, ensure_subscription, and remove_subscription functions.
  • The websocket_endpoint that publishes to Redis and handles disconnect.

Scroll up and confirm each code block is present in your file, in the order shown.

Test multi-worker scaling

Time to prove the fix works. You will run the same multi-worker setup that broke before and verify that messages now flow across all workers.

  • Start the server with 2 workers by running:
uvicorn main:app --workers 2 --port 8000
  • Open 3 browser tabs at http://127.0.0.1:8000/static/index.html.
  • In each tab, enter a different username (like Alice, Bob, Charlie) and join room general.

What do you expect to see this time? Will messages from one tab appear in all three?

  • Send a message from each tab.

Every message now appears in all 3 tabs, regardless of which worker each tab connected to. Redis is bridging the gap between processes.

  • Check the /rooms endpoint by opening http://127.0.0.1:8000/rooms in a new tab. You should see the room name and a count of connected users.

Messages still not reaching all tabs?

Confirm Redis is still running with docker ps. If the container stopped, restart it with docker start redis.

Also check that your main.py calls redis_client.publish() (not manager.broadcast()) in the WebSocket endpoint. Direct broadcast only reaches the local worker.

help me debug Redis Pub/Sub in my chat app

Why Redis Pub/Sub instead of a database?

A database write-and-poll approach adds latency. Each worker would need to query the database repeatedly to check for new messages.

Redis Pub/Sub delivers messages to subscribers instantly, with no polling delay. This is what makes real-time systems feel truly real-time. The tradeoff is that Pub/Sub is fire-and-forget. If no subscriber is listening, the message is lost.

Your chat now scales horizontally. Next up, you will take on a secret mission to add message history so new users can see what they missed.

Secret mission

Add Message History

Your chat works great for live conversations. But what happens when someone joins a room late? They see nothing. Add message history so new users instantly catch up on the last 20 messages they missed.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Docker container named redis running on port 6379.
  • The realtime-chat project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the project further.

  • The Redis Docker container uses minimal memory and CPU while idle.
  • Docker Desktop will continue running in the background.
  • Your project files remain on your Desktop ready to use.

Pause - I'll come back to this later

Stop the Redis container to free up memory. You can restart it later without losing your setup.

  • Stop the Redis container by running:
docker stop redis
  • When you're ready to resume, start it again by running:
docker start redis
  • Your project files stay on your Desktop. Reactivate the virtual environment with .venv\Scripts\activate (Windows) or source .venv/bin/activate (macOS/Linux) when you return.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

  • Remove the Redis container by running:
docker rm -f redis
  • Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/realtime-chat
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\realtime-chat
  • Docker Desktop itself remains installed for future projects. You can uninstall it from Settings > Apps > Apps & features on Windows, or by dragging it to Trash on macOS.

That's a Wrap!

That's a Wrap!

Nice work! You just built a real-time multi-room chat API that scales horizontally across multiple server processes.

You've learned how to:

  • Build a WebSocket server with full lifecycle management (connect, send, receive, disconnect) and implement the ConnectionManager pattern for multi-room broadcasting.
  • Hit the in-memory scaling wall and fix it with Redis Pub/Sub, the same pattern used in production chat systems like Slack and Discord.
  • Combine REST and WebSocket APIs in one FastAPI app, serving a /rooms endpoint for live room stats alongside persistent real-time connections.
  • Secret Mission: Added persistent message history using Redis Lists with automatic capping, so new users catch up on missed messages instantly.

Ready to quiz yourself?