Skip to content

Building My First Async Echo Server with Tokio

Posted on:September 19, 2025 at 02:23 PM

The first project on my Tokio learning path was an echo server. The idea is simple: accept a TCP connection, read incoming lines, and send them straight back to the client; the HelloWorld of systems programming.

Here is the full code I ended up with:

use tokio::io;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};

#[tokio::main]
async fn main() -> io::Result<()> {
    // (1) Bind a TCP listener to localhost on port 3000
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;

    loop {
        // (2) Wait for an incoming connection
        let (mut socket, addr) = listener.accept().await?;
        println!("New connection at {}", addr);

        // (3) Spawn a new async task to handle the connection
        tokio::spawn(async move {
            // (4) Split the socket into a reader and writer
            let (reader, mut writer) = socket.split();
            let mut buf = String::with_capacity(1024);
            let mut reader = BufReader::new(reader);
            println!("Starting to echo data for {}", addr);

            // (5) Read incoming lines in a loop
            while let Ok(_bytes) = reader.read_line(&mut buf).await {
                // (6) Allow clients to exit by typing "quit"
                if buf.trim() == "quit" {
                    break;
                }

                // (7) Write the received line back to the client
                writer.write_all(buf.as_bytes()).await.unwrap();
                buf.clear();
            }

            // (8) Print a message when the connection closes
            println!("Connection at {} closed", addr);
        });
    }
}

Walking Through the Code

  1. Binding a listener: TcpListener::bind tells Tokio to listen for TCP connections on port 3000. Because it is async, we await it.
  2. Accepting connections: The accept call blocks (asynchronously) until a new client connects. It gives us both the socket and the client address.
  3. Spawning tasks: Each connection runs in its own task thanks to tokio::spawn. This way, multiple clients can connect at the same time without blocking each other.
  4. Splitting the socket: Splitting the socket into a reader and writer allows us to read and write independently. Wrapping the reader in a BufReader makes reading lines easier.
  5. Reading lines: read_line waits until the client sends a full line (ending with \n). We reuse the same buffer for each message.
  6. Quit condition: If a client types quit, we break out of the loop and close the connection.
  7. Echoing messages: The server writes the same line back using write_all. Because we clear the buffer after each loop, the next line starts fresh.
  8. Connection closed: When the loop ends, we log that the client disconnected.

Testing the Echo Server

Once the server is running (cargo run), you can connect to it using nc (netcat):

nc 127.0.0.1 3000

Type any line and press Enter. You should see the same line echoed back. To disconnect, type:

quit

This manual testing is a simple way to verify that the server correctly echoes lines. You can even start multiple nc sessions to test if the server handles multiple clients concurrently.


Things I Learned

This project was small, but it gave me a good taste of writing async code in Rust.