Skip to Content
Course content

86: Building a Simple Chat Server with Tokio

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've spent some time talking about the theory of async Rust and how tokio manages tasks, but now it's time to actually put it to work. We're going to build a basic TCP chat server. The goal is simple: anyone who connects via telnet or netcat should be able to send a message, and that message should be broadcast to every other connected user.

Setting up the TCP listener

First, we need a way to accept incoming connections. I'm using TcpListener from tokio::net. The pattern here is a classic loop where we accept() a connection and then immediately spawn a new task to handle that specific client. I don't want one slow client to block the entire server from accepting new people.

use tokio::net::TcpListener;
use tokio::sync::broadcast;

#[tokio::main]
async fn main() -> tokio::io::Result<()> {
    let listener = TcpListener::bind("127.0.0.1:8080").await?;
    // We need a way to send messages to all clients. 
    // A broadcast channel is perfect for this "one-to-many" pattern.
    let (tx, _rx) = broadcast::channel(10);

    loop {
        let (socket, _) = listener.accept().await?;
        let tx = tx.clone();
        let rx = tx.subscribe();

        tokio::spawn(async move {
            if let Err(e) = handle_client(socket, tx, rx).await {
                eprintln!("Client error: {:?}", e);
            }
        });
    }
}

Dealing with the Read/Write split

Now for the handle_client function. Here's where I usually trip up if I'm rushing. Initially, I tried to just use the socket directly in a loop. But wait—I need to do two things simultaneously: I need to listen for messages coming from the client, and I need to listen for messages coming from the broadcast channel to send them back to the client.

I originally tried to just wrap the socket in an Arc<Mutex>, but that's overkill and creates a bottleneck. The right way to do this in Tokio is to split the TcpStream into a read half and a write half. This allows us to handle reading and writing in separate concurrent flows without fighting the borrow checker.

use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::{broadcast, broadcast::Receiver};

async fn handle_client(mut socket: TcpStream, tx: broadcast::Sender<String>, mut rx: Receiver<String>) -> tokio::io::Result<()> {
    let (mut reader, mut writer) = socket.split();
    let mut read_buf = [0u8; 1024];

    loop {
        tokio::select! {
            // Case 1: We receive a message from the client socket
            result = reader.read(&mut read_buf) => {
                let n = result?;
                if n == 0 { return Ok(()); } // Connection closed
                
                let msg = String::from_utf8_lossy(&read_buf[..n]).to_string();
                let _ = tx.send(msg); 
            }
            // Case 2: We receive a message from the broadcast channel
            result = rx.recv() => {
                let msg = result.map_err(|e| tokio::io::Error::new(tokio::io::ErrorKind::Other, e))?;
                writer.write_all(msg.as_bytes()).await?;
            }
        }
    }
}

Correcting the "Echo" mistake

When I first ran this, I noticed a frustrating bug: every message I sent was immediately echoed back to me. While that's technically a broadcast, it's annoying in a chat app. I realized that the tx.send(msg) sends the message to everyone who has a Receiver, including the person who just sent it.

To fix this properly in a real app, I'd give each user a unique ID and filter messages based on that ID. For this simple example, though, I'll just accept the echo or implement a simple check. But there's a more critical technical detail here: broadcast::channel can return a RecvError::Lagged if a client is too slow to keep up with the messages. In the code above, I'm mapping that error to a tokio::io::Error, which effectively drops the slow client. In a production system, you'd probably want to handle Lagged by notifying the user they missed some messages.

Why tokio::select! is the secret sauce

The most important part of this build is tokio::select!. Without it, you'd have to spawn two tasks per client—one for reading and one for writing. That would double your task overhead. select! lets us wait on multiple async operations and react to whichever one finishes first. It's the cleanest way to implement a "multiplexed" connection where the server is reacting to both the network and internal channel events simultaneously.




📋 Practical Task

Exercise: Implementing User Nicknames

Currently, the chat server is anonymous; you just see a wall of text. Your task is to modify the handle_client function to support a simple nickname system.

  • Modify the server so that the first message a client sends is treated as their nickname.
  • The nickname should not be broadcast to others, but it should be stored for that session.
  • Every subsequent message sent by that client should be broadcast in the format: "Nickname: message".
  • Hint: You'll need to introduce a state variable inside handle_client (e.g., let mut nickname: Option<String> = None;) to track whether the user has already identified themselves.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.