Telex logo Telex

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:

  1. Read the room's member list (from self.rooms)
  2. 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:

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.