Building a Chat Server in Rust #2: Rooms and Users
Post 2 of 6 in Building a Chat Server in Rust. Companion series: Rust Patterns That Matter.
Previous: #1: Hello, TCP | Next: #3: Parsing and Performance
Last time we built an echo server - one connection at a time, no state between clients. Now we add rooms, users, and broadcasting. State management gets real, and we hit six patterns in the process.
By the end of this post, clients connect, choose a username, join a lobby, and see
each other's messages. The code is on the
02-rooms-users
branch.
The problem: where does state live?
An echo server is stateless. A chat server is the opposite: it needs to know which users are connected, which rooms exist, and who is in each room. In a language with a garbage collector, you'd toss everything into shared mutable objects and move on. In Rust, the ownership system forces you to think about it - and that thinking produces better designs.
Pattern #8: Index-based design - the foundation
The most important decision comes first: how do we structure our data? The tempting approach is to store references between objects - a room holds references to its users, a user holds a reference to its room. In Rust, this means lifetimes everywhere, and you quickly hit walls: self-referential structs, borrow checker battles, and lifetime annotation soup.
The solution: don't store references. Store indices. Users go in a
Vec, rooms go in a Vec, and they refer to each other by ID:
pub struct Server {
pub users: Vec<Option<User>>,
pub rooms: Vec<Room>,
next_user_id: u64,
}
UserId and RoomId (our newtypes from
Stage 1) double as indices into these
Vecs. To look up a user:
impl UserId {
pub fn index(self) -> usize {
self.0 as usize
}
}
// Lookup is O(1) — just an array index.
let user = &self.users[user_id.index()];
This is the same pattern used by ECS frameworks, arenas, and graph libraries. No lifetimes, no self-referential structs, no borrow checker fights. Just data in arrays, connected by IDs.
Deep dive: Rust Patterns #8: Index-Based Design covers arenas, generational indices, and when to reach for this pattern.
Pattern #4: Interior mutability - RefCell
A room needs a member list. Multiple parts of the code need to read and modify it:
joining adds a member, leaving removes one, broadcasting reads the list. But the
room is behind a shared reference (&self) - you can't mutate
through & in Rust.
RefCell solves this by moving borrow checking to runtime. You call
.borrow() for shared access and .borrow_mut() for
exclusive access. If you violate the rules, it panics instead of refusing to compile:
use std::cell::RefCell;
let members = RefCell::new(vec![user_a, user_b]);
// Read the list — shared borrow.
let list = members.borrow();
// Mutate the list — exclusive borrow.
members.borrow_mut().push(user_c);
Deep dive: Rust Patterns #4: Interior Mutability explains when RefCell is the right tool and when it's a code smell.
Pattern #5: Shared ownership - Rc
RefCell gives us runtime borrow checking, but who owns the member
list? The Server's broadcast method needs to read the member list
while also accessing individual clients. Without shared ownership, you'd have to
clone the entire Vec<UserId> on every broadcast.
Rc makes this cheap - clone the handle (one counter increment),
iterate members, drop it:
use std::rc::Rc;
let members = Rc::new(data);
let handle = Rc::clone(&members); // cheap — just increments a counter
Deep dive: Rust Patterns #5: Shared Ownership covers Rc, Weak references, and reference cycles.
Pattern #6: Rc<RefCell<T>> - the combo
Combine them: Rc for shared ownership, RefCell for
interior mutability. This is the workhorse pattern for single-threaded shared
mutable state:
pub struct Room {
pub id: RoomId,
pub name: String,
pub members: Rc<RefCell<Vec<UserId>>>,
}
impl Room {
pub fn add_member(&self, user_id: UserId) {
let mut members = self.members.borrow_mut();
if !members.contains(&user_id) {
members.push(user_id);
}
}
pub fn remove_member(&self, user_id: UserId) {
self.members.borrow_mut().retain(|&id| id != user_id);
}
pub fn member_ids(&self) -> Vec<UserId> {
self.members.borrow().clone()
}
}
Notice: add_member and remove_member take
&self, not &mut self. The mutation happens
inside the RefCell. This is the entire point of interior mutability:
the method signature says "I only need shared access," but the implementation
can still mutate.
Deep dive: Rust Patterns #6: Rc<RefCell<T>> covers the full combo and the runtime panic risk.
Pattern #7: Split borrows - reading users while writing rooms
Broadcasting hits a classic borrow checker problem. To send a message to a room, we need to:
- Read the room's member list (from
self.rooms) - Write to each member's TCP stream (in
self.users)
If both were behind &mut self, the compiler would block it -
it sees one mutable borrow of the whole struct. But because users and
rooms are separate fields, Rust allows split borrows:
you can borrow different fields independently.
pub fn broadcast(
&mut self,
room_id: RoomId,
sender_id: UserId,
msg: &Message,
) -> Result<(), ChatError> {
let room = self.rooms
.get(room_id.index())
.ok_or_else(|| ChatError::UnknownRoom(room_id.to_string()))?;
// Read the member list, then release the borrow on rooms.
let members = room.member_ids();
let text = msg.to_string();
// Now mutably access users — rooms is no longer borrowed.
for &member_id in &members {
if member_id != sender_id {
if let Some(Some(member)) = self.users.get_mut(member_id.index()) {
member.send(&text);
}
}
}
Ok(())
}
The key technique: clone the data you need from one field (member_ids()
returns a Vec, releasing the borrow on rooms), then
access the other field. The compiler is happy because it can see that
self.rooms and self.users are disjoint.
Deep dive: Rust Patterns #7: Split Borrows explains why the compiler can split struct borrows but not method borrows.
Pattern #9: Drop / RAII - automatic cleanup
What happens when a client disconnects? Their TCP stream should close, they should
be removed from all rooms, and other users should see a departure notice. In many
languages, you'd register a callback or remember to call cleanup().
In Rust, you implement Drop:
impl Drop for User {
fn drop(&mut self) {
println!("[{}] {} dropped — connection closed", self.id, self.username);
}
}
When a User is removed from the server - by setting their slot
to None - Rust drops the value. The TcpStream
inside is also dropped, which closes the socket. Our Drop impl adds
logging. No manual close, no cleanup function, no possibility of forgetting:
pub fn remove_user(&mut self, user_id: UserId) {
// Remove from all rooms first.
for room in &self.rooms {
room.remove_member(user_id);
}
// Drop the user — RAII cleanup happens here.
if let Some(slot) = self.users.get_mut(user_id.index()) {
*slot = None; // User::drop() runs, stream closes
}
}
This is RAII: Resource Acquisition Is
Initialization. The resource (TCP connection) is acquired when the
User is created and released when it's dropped. The lifecycle is tied
to the value's scope, not to manual management.
Deep dive: Rust Patterns #9: Drop and RAII
covers custom Drop, drop order, and ManuallyDrop.
The full client loop
Here's how handle_client ties it all together: register the user,
join the lobby, read messages, broadcast, and clean up on disconnect:
pub fn handle_client(&mut self, stream: TcpStream) -> Result<(), ChatError> {
let peer = stream.peer_addr()?;
let mut write_stream = stream.try_clone()?;
let reader = BufReader::new(stream.try_clone()?);
writeln!(write_stream, "Enter your username:")?;
let mut lines = reader.lines();
let username = match lines.next() {
Some(Ok(name)) if !name.trim().is_empty() => name.trim().to_string(),
_ => return Ok(()),
};
let user_id = self.add_user(username.clone(), stream);
let lobby = RoomId::new(0);
self.join_room(user_id, lobby)?;
// Read messages until disconnect.
let result = self.client_loop(user_id, &username, lobby, lines);
// Cleanup — announce departure, then remove.
// remove_user drops the User, triggering RAII cleanup.
self.leave_room(user_id, lobby);
self.remove_user(user_id);
result
}
The cleanup at the bottom runs whether the client loop returned Ok or
Err - we call leave_room and remove_user
unconditionally. The User::drop() handles the TCP stream cleanup.
Try it
# Terminal 1
git checkout 02-rooms-users
cargo run
# Terminal 2
nc 127.0.0.1 8080
alice # → Welcome, alice! You're in #lobby.
hello everyone # → <alice> hello everyone
# Terminal 3
nc 127.0.0.1 8080
bob # → Welcome, bob! You're in #lobby.
# (alice sees: * bob joined #lobby)
hi alice # → alice sees: <bob> hi alice
Note: the server is still single-threaded, so only one client is active at a time. The second client waits until the first disconnects. We'll fix that in Stage 5.
What we have, what's missing
We now have a stateful server with six patterns working together:
- Index-based design - users and rooms in
Vecs, referenced by newtype IDs. No lifetimes, no self-referential structs. - RefCell - runtime borrow checking for the member list behind shared references.
- Rc - shared ownership of the
RefCell-wrapped member list. - Rc<RefCell> - the combo: shared and mutable. The workhorse of single-threaded state.
- Split borrows - reading
self.roomswhile writingself.users, because they're separate struct fields. - Drop / RAII -
Usercleanup is automatic. When the value is dropped, the TCP stream closes and the log message prints.
What's missing: our message format is still ad hoc - just raw text lines. Next time we build a proper wire protocol with zero-copy parsing and custom iterators.
Telex