DEV Community

Cover image for Why One Thread Is Enough: Building a Sequential TCP Server in Rust
Kishan Agarwal
Kishan Agarwal

Posted on Originally published at Medium

Why One Thread Is Enough: Building a Sequential TCP Server in Rust

Look, I know "single-threaded server" sounds like you forgot to finish building it. We live in a world of thread pools, async runtimes, and horizontal scaling. One thread sounds like a step backwards.

It is not. And the reason it works comes down to something most developers walk past every single day without noticing.

If you come from a JavaScript or TypeScript background, you already live with single-threaded execution. You know async/await. You know what it means when something blocks. This post takes that intuition, applies it to a real Rust TCP server built from scratch, and shows you exactly why one thread can genuinely be all you need.

What Is a Thread, Actually

A thread is the smallest unit of execution that an operating system can independently manage.

Let me explain it in simple words. Your program runs inside a process. A process can run one sequence of instructions at a time (single-threaded) or many sequences simultaneously (multi-threaded). Each of those sequences is a thread. The OS switches between them so fast it feels like they are all running at once.

Single-threaded systems execute instructions in sequence. One thing completes, then the next one starts. Multi-threaded systems split tasks across multiple paths, running them concurrently.

The Multithreading Trap Nobody Mentions

Here is a scenario. You want ten threads to each increment a shared counter 1,000 times. Final count should be 10,000.

// In C++, Java, or Go you might write something like this.
// Ten threads, all reading and writing the same variable.
//
// Thread 1 reads counter (value: 50).
// Thread 2 reads counter (value: 50) — before Thread 1 writes back.
// Thread 1 writes 51.
// Thread 2 writes 51.
// One increment just disappeared.
//
// Final result: somewhere between 1 and 10,000. Different every run.
// This is a data race.

Rust's borrow checker refuses to let you compile this mistake. That is actually the point. Rust forces you to acknowledge the problem before it bites you at runtime.

The fix most people reach for is a Mutex.

use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            for _ in 0..1000 {
                let mut num = counter.lock().unwrap(); // Thread BLOCKS here
                *num += 1;
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Final counter: {}", *counter.lock().unwrap());
}

This gets you to exactly 10,000. Every time. The Mutex ensures only one thread touches the counter at a time.

But here is what actually happened. You started ten threads, warm and ready to run, then immediately forced nine of them to wait for the one that holds the lock. You paid the cost of spawning ten threads to get the effective throughput of one.

Sounds like a massive headache, right?

For the right problems, locks and threads are absolutely the correct tools. But for a network server, there is a much cleaner answer hiding in plain sight.

Why Latency Changes Everything

Here is the core insight that makes a single-threaded server genuinely work.

Think of the reservation counter at a small railway station. The clerk is fast. Booking a ticket takes five seconds of actual work. But the passenger fumbles for their Aadhaar card, debates berth preferences, counts coins. That takes three minutes. The clerk is idle for most of it, waiting on the passenger.

In a network server, your thread is that clerk. Processing a request takes microseconds. But the client's data travels across routers, switches, and sometimes half the country before it reaches your port. A typical network round trip is 20ms to 200ms. Your 1ms of processing is buried inside that.

The obvious question here is: if the thread is idle while waiting for network data anyway, why spawn more threads at all?

You do not always need to. If your processing is fast and the bottleneck is the network, one thread can handle an enormous amount of work. Redis runs its core command loop on a single thread. It serves hundreds of thousands of requests per second. Not because it has many threads. Because each command is microseconds-fast and the network is always the slow part.

The Real Engineering Part

Here is the complete server.

use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};

fn main() {
    const HOST: &str = "0.0.0.0";
    const PORT: i32 = 8080;
    println!("Server running on {}:{}", HOST, PORT);
    
    let mut clients = 0;
    let listener = TcpListener::bind(format!("{}:{}", HOST, PORT)).unwrap();
    
    for stream in listener.incoming() {
        match stream {
            Err(e) => {
                println!("Error accepting connection: {}", e);
            }
            Ok(mut stream) => { 
                clients += 1;
                if let Ok(addr) = stream.peer_addr() {
                    println!("New connection from {}, total: {}", addr, clients);
                }
                loop {
                    match read_command(&mut stream) {
                        Ok(cmd) => {
                            if cmd.is_empty() {
                                clients -= 1;
                                println!("Client disconnected, total: {}", clients);
                                break;
                            }
                            println!("Received command: {}", cmd);
                            
                            if let Err(e) = respond(&mut stream, &cmd) {
                                println!("Error responding to client: {}", e);
                            }
                        }
                        Err(e) => {
                            clients -= 1;
                            println!("Error reading command: {}", e);
                            println!("Client disconnected, total: {}", clients);
                            break;
                        }
                    }
                }
            }
        }
    }
}

fn read_command(stream: &mut TcpStream) -> Result<String, std::io::Error> {
    let mut buf = [0; 512];
    let n = stream.read(&mut buf)?; 
    Ok(String::from_utf8_lossy(&buf[..n]).to_string())
}

fn respond(stream: &mut TcpStream, cmd: &str) -> Result<(), std::io::Error> {
    stream.write_all(cmd.as_bytes())?;
    Ok(())
}

TcpListener::bind() opens a TCP socket and starts listening on port 8080. listener.incoming() returns an iterator. Each iteration blocks until a new client connects and then hands you a TcpStream for that client.

The outer for loop accepts one connection at a time. The inner loop handles that single client: read a message, echo it back, repeat until the connection drops or the client sends an empty message. read_command() reads up to 512 bytes off the stream. respond() writes those exact bytes right back.

No Arc. No Mutex. No thread pool. The borrow checker does not even break a sweat.

You might be thinking: "But what if two clients connect at the same time? Client 2 just waits forever?"

Not forever. Client 2 waits until Client 1 disconnects, which the inner loop detects through an empty read returning Ok("") or an error on the stream. The moment that happens, the outer for loop advances and Client 2 gets the thread's full attention.

This is the intentional trade-off. Sequential access to a single, fast resource. It is the same model Redis and Valkey follow for their core command processing. The architecture only breaks down when processing becomes slow, which brings us to the one honest limitation.

The Catch

This model has one hard requirement: your processing must be fast.

The moment your server queries a database, reads from disk, or runs any non-trivial computation before responding, Client 2 waits for all of that. Not just the network hop. Every millisecond of work. The latency gap that makes this model work is exactly 0ms wide when your thread is burning CPU cycles instead of waiting on I/O.

Redis avoids this entirely by keeping everything in memory. Memory access is nanoseconds. The network is milliseconds. That gap is the whole reason the model holds up.

For servers that do variable or slow work, an async runtime like Tokio with non-blocking I/O is the right call. But for fast in-memory servers, this sequential approach is not a compromise. It is a deliberate architectural decision backed by decades of production use.

DIY: Build It Yourself

Get the server running:

cargo run

Connect from a second terminal:

nc localhost 8080

Type anything and press enter. Your text echoes right back. Now open a third terminal and try connecting while the first session is still active. Watch it wait. Close the first session and watch the third connect instantly.

That pause is the architecture working exactly as designed. One thread. One client. Sequential.

From here, try adding a command parser. Handle PING with a PONG response, ECHO <message> with just the message back, and QUIT to close the connection cleanly. You will have the skeleton of a Redis-style wire protocol before you know it.

Try implementing this. It is one thing to read about single-threaded servers, but it is a whole different feeling when you watch Client 2 snap to life the exact moment Client 1 drops.

What Comes After This

The natural next step is non-blocking I/O. Instead of one thread serving clients in sequence, a single thread polls many open connections at once using OS primitives like epoll on Linux. Tokio builds on exactly this. That is where the true event loop lives, and that is where the model scales from dozens of clients to hundreds of thousands.

Happy Exploration!

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.