Skip to content
Build a Real-Time Chat App with WebSockets (Step by Step)

Build a Real-Time Chat App with WebSockets (Step by Step)

DodaTech Updated Jun 19, 2026 8 min read

Build a real-time chat app using WebSockets with Python and FastAPI that handles multiple users, room-based messaging, and live join/leave notifications.

What You’ll Build

You’ll build a browser-based chat room where multiple users can connect simultaneously, send messages visible to everyone in the same room, see when someone joins or leaves, and switch between rooms. Think of a lightweight Slack channel — without the rich text formatting, but with the same real-time feel.

Why WebSockets Matter

Traditional HTTP works like sending a letter — you ask, the server answers, then the connection closes. WebSockets are like a phone call — once connected, both sides can talk anytime without waiting for the other to ask. This makes them perfect for chat apps, live notifications, stock tickers, multiplayer games, and collaborative editing tools. At DodaTech, real-time file sync in DodaZIP uses WebSocket-like patterns to push conversion status updates to users.

Prerequisites

  • Python 3.9+ installed
  • Basic JavaScript knowledge for the frontend
  • Familiarity with FastAPI or any Python web framework

Step 1: Setup the Project

mkdir chat-app
cd chat-app
python -m venv venv
source venv/bin/activate   # Windows: venv\Scripts\activate
pip install fastapi uvicorn websockets jinja2

Create this project structure:

chat-app/
├── main.py          # FastAPI server + WebSocket handler
├── templates/
│   └── chat.html    # Simple browser-based UI
└── static/
    └── style.css    # Optional styling

Step 2: The WebSocket Server

Let’s build the server step by step. We’ll use FastAPI’s built-in WebSocket support:

# main.py
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from typing import Set, Dict
import json

app = FastAPI()

# Store active connections: room_name -> set of websockets
rooms: Dict[str, Set[WebSocket]] = {}

@app.get("/")
async def get():
    with open("templates/chat.html") as f:
        return HTMLResponse(f.read())

@app.websocket("/ws/{room}")
async def websocket_endpoint(websocket: WebSocket, room: str):
    await websocket.accept()

    if room not in rooms:
        rooms[room] = set()
    rooms[room].add(websocket)

    # Notify others that someone joined
    await broadcast(room, {"type": "system", "message": "A user joined the chat"}, exclude=None)

    try:
        while True:
            data = await websocket.receive_text()
            message = json.loads(data)
            await broadcast(room, {
                "type": "message",
                "username": message.get("username", "Anonymous"),
                "text": message["text"]
            }, exclude=None)
    except WebSocketDisconnect:
        rooms[room].discard(websocket)
        if not rooms[room]:
            del rooms[room]
        await broadcast(room, {"type": "system", "message": "A user left the chat"}, exclude=None)

async def broadcast(room: str, message: dict, exclude: WebSocket = None):
    """Send a message to all connected clients in a room."""
    if room not in rooms:
        return
    disconnected = set()
    for ws in rooms[room]:
        if ws == exclude:
            continue
        try:
            await ws.send_json(message)
        except Exception:
            disconnected.add(ws)
    # Clean up dead connections
    for ws in disconnected:
        rooms[room].discard(ws)
    if not rooms[room]:
        del rooms[room]

Key concepts explained:

  • WebSocket.accept() — handshake complete, connection established
  • receive_text() — wait for incoming text from a client
  • send_json() — send a Python dict as JSON to the client
  • WebSocketDisconnect — raised when a client drops, we handle cleanup
  • broadcast() — send one message to everyone in a room

Step 3: The Frontend (Browser Client)

<!-- templates/chat.html -->
<!DOCTYPE html>
<html>
<head>
    <title>WebSocket Chat</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
        .chat-container { border: 1px solid #ddd; border-radius: 8px; overflow: hidden; }
        .messages { height: 400px; overflow-y: auto; padding: 16px; background: #fafafa; }
        .msg { margin-bottom: 12px; padding: 8px 12px; border-radius: 8px; max-width: 80%; }
        .msg.user { background: #007bff; color: white; margin-left: auto; }
        .msg.other { background: #e9ecef; }
        .msg.system { text-align: center; font-size: 0.85em; color: #888; font-style: italic; }
        .msg .author { font-weight: bold; font-size: 0.85em; margin-bottom: 4px; }
        .controls { display: flex; padding: 12px; gap: 8px; background: white; border-top: 1px solid #ddd; }
        .controls input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px; }
        .controls button { padding: 10px 20px; background: #007bff; color: white; border: none; border-radius: 6px; cursor: pointer; }
        .controls button:hover { background: #0056b3; }
        .room-select { padding: 12px; display: flex; gap: 8px; align-items: center; background: #f8f9fa; border-bottom: 1px solid #ddd; }
    </style>
</head>
<body>
    <h1>WebSocket Chat</h1>
    <div class="chat-container">
        <div class="room-select">
            <label>Room:</label>
            <input type="text" id="roomInput" value="general" placeholder="Room name">
            <button onclick="joinRoom()">Join</button>
        </div>
        <div id="messages" class="messages"></div>
        <div class="controls">
            <input type="text" id="usernameInput" value="User" placeholder="Your name">
            <input type="text" id="messageInput" placeholder="Type a message..." onkeydown="if(event.key==='Enter') sendMessage()">
            <button onclick="sendMessage()">Send</button>
        </div>
    </div>

    <script>
        let ws = null;

        function joinRoom() {
            const room = document.getElementById('roomInput').value.trim() || 'general';
            if (ws) ws.close();

            ws = new WebSocket(`ws://${location.host}/ws/${room}`);

            ws.onmessage = function(event) {
                const data = JSON.parse(event.data);
                const container = document.getElementById('messages');

                const div = document.createElement('div');
                div.className = 'msg ' + (data.type === 'system' ? 'system' : 'other');
                if (data.username && data.username === document.getElementById('usernameInput').value) {
                    div.className = 'msg user';
                }

                if (data.type === 'system') {
                    div.textContent = data.message;
                } else {
                    const author = document.createElement('div');
                    author.className = 'author';
                    author.textContent = data.username;
                    div.appendChild(author);
                    div.appendChild(document.createTextNode(data.text));
                }

                container.appendChild(div);
                container.scrollTop = container.scrollHeight;
            };

            ws.onclose = function() {
                const container = document.getElementById('messages');
                const div = document.createElement('div');
                div.className = 'msg system';
                div.textContent = 'Disconnected from server';
                container.appendChild(div);
            };
        }

        function sendMessage() {
            if (!ws || ws.readyState !== WebSocket.OPEN) return;
            const username = document.getElementById('usernameInput').value || 'Anonymous';
            const text = document.getElementById('messageInput').value.trim();
            if (!text) return;

            ws.send(JSON.stringify({ username, text }));
            document.getElementById('messageInput').value = '';
        }

        // Auto-join on page load
        window.onload = joinRoom;
    </script>
</body>
</html>

Expected output: Open http://localhost:8000 in two browser tabs. Type a message in one tab — it appears in the other tab instantly. Join a different room in one tab and messages stay isolated per room.

Step 4: Run the App

uvicorn main:app --reload --host 0.0.0.0 --port 8000

Visit http://localhost:8000. Open multiple tabs. You’ll see messages sync instantly across all tabs in the same room. Try sending a message — the server broadcasts it to every connected client. Close one tab — a “user left” notification appears.

Architecture


sequenceDiagram
    participant User1 as User A (Tab 1)
    participant Server as FastAPI Server
    participant User2 as User B (Tab 2)

    User1->>Server: WebSocket /ws/general
    Server->>User1: Accept connection
    Server->>User2: "User joined" (broadcast)
    
    User2->>Server: WebSocket /ws/general
    Server->>User2: Accept connection
    Server->>User1: "User joined" (broadcast)
    
    User1->>Server: {"username":"Alice","text":"Hi!"}
    Server->>User1: {"username":"Alice","text":"Hi!"}
    Server->>User2: {"username":"Alice","text":"Hi!"}
    
    User2->>Server: {"username":"Bob","text":"Hey!"}
    Server->>User1: {"username":"Bob","text":"Hey!"}
    Server->>User2: {"username":"Bob","text":"Hey!"}
    
    User1-->>Server: Disconnect
    Server->>User2: "User left" (broadcast)

Common Errors

1. WebSocket connection fails with 404 Your WebSocket URL must match the server route. If the server mounts /ws/{room}, the client URL must be ws://host/ws/roomname. Check for typos in the room path. Also ensure you’re using ws:// (not wss://) for local development without TLS.

2. Messages not showing for other users The most common cause: the broadcast() function isn’t being called, or it’s sending to the wrong room. Add a print statement inside broadcast() to verify it fires: print(f"Broadcasting to room {room}: {message}"). Also check that rooms[room] contains the expected WebSocket objects.

3. “WebSocket is not open” when sending The user tried to send a message before the connection completed. Wrap sends in a readyState check: if (ws.readyState === WebSocket.OPEN). You can also buffer messages until onopen fires.

4. Memory leak with abandoned rooms If users disconnect without cleanup, old WebSocket objects accumulate. Always remove dead connections in a try/except inside broadcast(), and delete empty room sets. Our code handles this, but if you’re modifying it, don’t forget this step.

Practice Questions

1. What’s the difference between HTTP and WebSocket? HTTP is request-response (client asks, server answers, connection closes). WebSocket is full-duplex (both sides can send anytime after the initial handshake). Use WebSocket when you need real-time, server-pushed updates.

2. How does our server track which users are in which room? Using a dictionary called rooms where keys are room names (strings) and values are Python sets of WebSocket connections. Each room has its own set, so broadcasting scopes correctly.

3. What happens when the server goes down? All WebSocket connections drop. The browser’s onclose event fires. For production, you’d implement reconnection logic — exponential backoff with a max retry limit.

4. Challenge: Add typing indicators Send a {"type": "typing", "username": "Alice"} message when the user types. Display “Alice is typing…” below the message area, and clear it after 2 seconds of inactivity. This requires debouncing on the client and filtering on the server.

5. Challenge: Private messaging Add a /msg username text command that sends a message only to a specific user. You’ll need to track which user is connected to which WebSocket and route messages accordingly.

FAQ

How do I deploy a WebSocket app?
Use Uvicorn behind NGINX with proxy_pass for WebSocket upgrades: proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade";. For cloud, services like Railway, Fly.io, and Render support WebSocket natively.
Can WebSockets work with HTTPS?
Yes. The browser uses wss:// (WebSocket Secure) for encrypted connections, just like HTTPS. The server needs an SSL certificate. FastAPI + Uvicorn supports SSL with the --ssl-keyfile and --ssl-certfile flags.
How do I scale WebSockets beyond one server?
Use a pub/sub layer like Redis. Instead of broadcasting to in-memory sets, publish messages to a Redis channel. All server instances subscribe and forward to their local connections. The fastapi-socketio library wraps this pattern.

Next Steps

  • Add message persistence with SQLite so history survives restarts
  • Explore WebSocket security — validate origins, rate-limit messages, sanitize input
  • Try building the Real-Time Data Dashboard project for another WebSocket use case
  • Learn how to scale with Redis pub/sub in the Redis tutorial

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro