Build a Real-Time Chat App with WebSockets (Step by Step)
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 jinja2Create this project structure:
chat-app/
├── main.py # FastAPI server + WebSocket handler
├── templates/
│ └── chat.html # Simple browser-based UI
└── static/
└── style.css # Optional stylingStep 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 establishedreceive_text()— wait for incoming text from a clientsend_json()— send a Python dict as JSON to the clientWebSocketDisconnect— raised when a client drops, we handle cleanupbroadcast()— 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 8000Visit 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
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