WebSocket Rooms: Isolating Multi-Channel Chats
Most real-world applications need multiple isolated channels: Slack has channels, multiplayer games have rooms, collaborative apps have workspaces. WebSocket rooms allow a single server to manage separate user groups where messages in #general don't leak to #random, and a disconnect in one room doesn't affect others. This article refactors the chat server to support rooms, enabling complex topologies without new infrastructure.
Room Architecture: Hierarchical User Storage
Instead of a flat dictionary of users, store users nested by room. Each room tracks its own connection set and broadcast scope:
from fastapi import FastAPI, WebSocket, Query
from fastapi.responses import HTMLResponse
from typing import Set, Dict
from datetime import datetime
import json
class RoomManager:
def __init__(self):
self.rooms: Dict[str, Dict[str, dict]] = {} # room_id -> {client_id -> user}
def ensure_room(self, room_id: str):
if room_id not in self.rooms:
self.rooms[room_id] = {}
async def connect(self, room_id: str, client_id: str, username: str, websocket: WebSocket):
self.ensure_room(room_id)
self.rooms[room_id][client_id] = {
"username": username,
"websocket": websocket,
"joined_at": datetime.now()
}
await websocket.accept()
# Notify peers in this room
await self.broadcast_to_room(room_id, {
"type": "user_joined",
"username": username,
"active_users": [u["username"] for u in self.rooms[room_id].values()]
})
def disconnect(self, room_id: str, client_id: str):
if room_id not in self.rooms or client_id not in self.rooms[room_id]:
return None
user = self.rooms[room_id].pop(client_id)
return user["username"]
async def broadcast_to_room(self, room_id: str, message: dict):
if room_id not in self.rooms:
return
disconnected = []
for client_id, user in self.rooms[room_id].items():
try:
await user["websocket"].send_json(message)
except Exception:
disconnected.append(client_id)
for client_id in disconnected:
self.rooms[room_id].pop(client_id, None)
async def handle_message(self, room_id: str, client_id: str, text: str):
if room_id not in self.rooms or client_id not in self.rooms[room_id]:
return
user = self.rooms[room_id][client_id]
message = {
"type": "chat_message",
"room": room_id,
"username": user["username"],
"text": text,
"timestamp": datetime.now().isoformat()
}
await self.broadcast_to_room(room_id, message)
def get_room_info(self, room_id: str):
if room_id not in self.rooms:
return None
return {
"room_id": room_id,
"user_count": len(self.rooms[room_id]),
"users": [u["username"] for u in self.rooms[room_id].values()]
}
app = FastAPI()
manager = RoomManager()
@app.get("/")
async def get():
return HTMLResponse("""
<html>
<body>
<h1>Multi-Room Chat</h1>
<div style="display: flex;">
<div style="flex: 1;">
<h3>Rooms</h3>
<button onclick="joinRoom('#general')">#general</button>
<button onclick="joinRoom('#random')">#random</button>
<button onclick="joinRoom('#tech')">#tech</button>
</div>
<div style="flex: 2;">
<h3 id="current_room">#general</h3>
<div id="messages" style="border: 1px solid black; height: 400px; overflow-y: auto; padding: 10px;"></div>
<input type="text" id="msg" placeholder="Type a message..." />
<button onclick="sendMsg()">Send</button>
<div id="status"></div>
</div>
</div>
<script>
const username = 'User' + Math.floor(Math.random() * 1000);
let currentRoom = '#general';
let ws;
function joinRoom(room) {
currentRoom = room;
if (ws) ws.close();
document.getElementById('current_room').innerText = currentRoom;
document.getElementById('messages').innerHTML = '';
ws = new WebSocket(`ws://localhost:8000/ws?room=${encodeURIComponent(currentRoom)}&username=${encodeURIComponent(username)}&client_id=${Math.random()}`);
ws.onopen = () => {
document.getElementById('status').innerText = `Connected to ${currentRoom}`;
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
const div = document.createElement("div");
if (msg.type === 'user_joined') {
div.style.color = 'blue';
div.innerText = `${msg.username} joined. (${msg.active_users.length} users)`;
} else if (msg.type === 'chat_message') {
div.innerText = `${msg.username}: ${msg.text}`;
}
document.getElementById('messages').appendChild(div);
document.getElementById('messages').scrollTop = document.getElementById('messages').scrollHeight;
};
}
function sendMsg() {
const msg = document.getElementById('msg').value;
if (msg && ws && ws.readyState === WebSocket.OPEN) {
ws.send(msg);
document.getElementById('msg').value = '';
}
}
joinRoom('#general');
</script>
</body>
</html>
""")
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, room: str = Query(...), username: str = Query(...), client_id: str = Query(...)):
await manager.connect(room, client_id, username, websocket)
try:
while True:
data = await websocket.receive_text()
await manager.handle_message(room, client_id, data)
except Exception:
username = manager.disconnect(room, client_id)
if username:
await manager.broadcast_to_room(room, {
"type": "user_left",
"username": username,
"active_users": [u["username"] for u in manager.rooms.get(room, {}).values()]
})
@app.get("/api/rooms/{room_id}")
async def get_room_info(room_id: str):
info = manager.get_room_info(room_id)
return info if info else {"error": "Room not found"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Key Structural Changes
The rooms dictionary is now two levels deep: rooms[room_id][client_id]. When a user connects, the endpoint receives a room query parameter and calls manager.connect(room, ...) instead of a global room. The broadcast_to_room() method only sends to users in that specific room, ensuring isolation.
When a user joins #random, they don't receive messages from #general because broadcast_to_room("#general", message) skips them. This is the core of room isolation: every broadcast operation is scoped.
Switching Rooms Without Reconnection
The HTML client above implements room switching by closing the old WebSocket (if (ws) ws.close()) and opening a new one with the new room parameter. This is simple but creates a brief disconnect and can lose messages in flight. A production app might use a "switch_room" message protocol instead:
async def handle_message(self, room_id: str, client_id: str, text: str):
msg = json.loads(text) if text.startswith("{") else {"type": "chat", "text": text}
if msg.get("type") == "switch_room":
new_room = msg.get("new_room")
# Remove from old room
self.disconnect(room_id, client_id)
# Add to new room (same websocket)
await self.connect(new_room, client_id, user_name, websocket)
return
# Normal message handling
await self.broadcast_to_room(room_id, ...)
This avoids closure overhead, but adds protocol complexity. For most applications, closing and reconnecting is acceptable.
REST Endpoint for Room Discovery
The GET /api/rooms/{room_id} endpoint allows clients to query room metadata (user count, active members) without connecting. This is useful for a lobby where users see which rooms are active before joining.
Memory Considerations at Scale
Storing users in nested dictionaries scales linearly with connected users, but at 100,000 concurrent users across 1,000 rooms, memory overhead becomes significant. At that scale, consider:
- Moving user state to a database and rooms to Redis (covered in later articles).
- Using a connection pooling library to manage WebSocket lifecycle separately from in-memory dictionaries.
- Periodically archiving inactive rooms to disk.
For typical applications (under 10,000 concurrent users), nested dictionaries work fine.
Key Takeaways
- Rooms are partitions of users; nesting users by room ID in a dictionary enables isolation.
broadcast_to_room()ensures messages stay within their channel, preventing information leakage.- Closing and reconnecting with a new room parameter is simple; protocol-based room switching avoids connection churn.
- REST endpoints for room metadata enable lobbies and discovery without real-time overhead.
- Memory scales linearly; for very large deployments, use external stores (Redis, database).
Frequently Asked Questions
Can a user be in multiple rooms simultaneously?
Yes, if you extend the data model: instead of one WebSocket per user, maintain a mapping of user to multiple room IDs, and connect each to a separate endpoint. Each room gets its own WebSocket. The client establishes parallel connections for each room.
How do I notify users when a room is created or destroyed?
Create an admin/global channel that all clients join by default. When a room is created, broadcast a room_created message to the global channel. Clients can subscribe to these events and update their room list dynamically.
What if a message is sent to a room with no users?
The broadcast_to_room() method will iterate over an empty dictionary and do nothing, which is fine. When the first user joins the empty room, they see no prior messages (unless you persist history to a database).
Can I password-protect a room?
Before accept(), validate the password: if password != room_passwords.get(room_id): await websocket.close(code=1008); return. The client must send the password in a query parameter or message, and the server verifies before accepting the connection.