Skip to content

Web Server Project ​

Updated Mar 2026

Build a multithreaded web server from scratch using only the standard library. This project ties together TCP networking, HTTP parsing, concurrency with threads, channels, mutexes, and graceful shutdown. Based on Chapter 21 of The Rust Programming Language.

Architecture Overview ​

Phase 1: Single-Threaded Server ​

TCP Listener ​

rust
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();

    for stream in listener.incoming() {
        let stream = stream.unwrap();
        handle_connection(stream);
    }
}

listener.incoming() returns an iterator of TcpStream connections. Each stream represents an open connection between client and server.

Reading HTTP Requests ​

rust
use std::io::{BufReader, prelude::*};
use std::net::TcpStream;

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    println!("Request: {request_line}");
}

HTTP request format:

Method Request-URI HTTP-Version CRLF
headers CRLF
CRLF
message-body

Sending Responses ​

rust
use std::fs;

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = if request_line == "GET / HTTP/1.1" {
        ("HTTP/1.1 200 OK", "hello.html")
    } else {
        ("HTTP/1.1 404 NOT FOUND", "404.html")
    };

    let contents = fs::read_to_string(filename).unwrap();
    let length = contents.len();

    let response =
        format!("{status_line}\r\nContent-Length: {length}\r\n\r\n{contents}");

    stream.write_all(response.as_bytes()).unwrap();
}

HTTP response format:

HTTP-Version Status-Code Reason-Phrase CRLF
headers CRLF
CRLF
message-body

The Single-Thread Problem ​

If one request takes 5 seconds (like a /sleep endpoint), ALL other requests are blocked:

rust
use std::thread;
use std::time::Duration;

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let request_line = buf_reader.lines().next().unwrap().unwrap();

    let (status_line, filename) = match &request_line[..] {
        "GET / HTTP/1.1" => ("HTTP/1.1 200 OK", "hello.html"),
        "GET /sleep HTTP/1.1" => {
            thread::sleep(Duration::from_secs(5));  // blocks ALL requests
            ("HTTP/1.1 200 OK", "hello.html")
        }
        _ => ("HTTP/1.1 404 NOT FOUND", "404.html"),
    };

    // ... send response
}

Phase 2: Thread Pool ​

Why Not Thread-Per-Request ​

Spawning a new thread for every request is a denial-of-service vulnerability. An attacker can exhaust system resources by opening many connections. A thread pool limits concurrency to a fixed number of workers.

Thread Pool Design ​

The Job Type ​

rust
type Job = Box<dyn FnOnce() + Send + 'static>;
  • FnOnce(): a closure that runs once and takes no arguments
  • Send: safe to transfer between threads
  • 'static: no borrowed data that might dangle

ThreadPool Implementation ​

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

pub struct ThreadPool {
    workers: Vec<Worker>,
    sender: Option<mpsc::Sender<Job>>,
}

type Job = Box<dyn FnOnce() + Send + 'static>;

impl ThreadPool {
    /// Create a new ThreadPool.
    ///
    /// The size is the number of threads in the pool.
    ///
    /// # Panics
    ///
    /// The `new` function will panic if the size is zero.
    pub fn new(size: usize) -> ThreadPool {
        assert!(size > 0);

        let (sender, receiver) = mpsc::channel();
        let receiver = Arc::new(Mutex::new(receiver));

        let mut workers = Vec::with_capacity(size);

        for id in 0..size {
            workers.push(Worker::new(id, Arc::clone(&receiver)));
        }

        ThreadPool {
            workers,
            sender: Some(sender),
        }
    }

    pub fn execute<F>(&self, f: F)
    where
        F: FnOnce() + Send + 'static,
    {
        let job = Box::new(f);
        self.sender.as_ref().unwrap().send(job).unwrap();
    }
}

Worker Implementation ​

rust
struct Worker {
    id: usize,
    thread: Option<thread::JoinHandle<()>>,
}

impl Worker {
    fn new(id: usize, receiver: Arc<Mutex<mpsc::Receiver<Job>>>) -> Worker {
        let thread = thread::spawn(move || loop {
            let message = receiver.lock().unwrap().recv();

            match message {
                Ok(job) => {
                    println!("Worker {id} got a job; executing.");
                    job();
                }
                Err(_) => {
                    println!("Worker {id} disconnected; shutting down.");
                    break;
                }
            }
        });

        Worker {
            id,
            thread: Some(thread),
        }
    }
}

Key Design Decisions ​

Arc<Mutex<Receiver>> -- Shared Ownership of the Channel

  • mpsc::channel() creates a single-producer, multiple-consumer channel
  • But Receiver is not Clone -- only one receiver exists
  • Arc allows multiple workers to share ownership
  • Mutex ensures only one worker reads from the channel at a time

Why let message = ... Instead of while let

rust
// BAD: holds the MutexGuard through job execution
while let Ok(job) = receiver.lock().unwrap().recv() {
    job();  // lock is STILL held here -- blocks other workers!
}

// GOOD: lock is released before job executes
loop {
    let message = receiver.lock().unwrap().recv();
    match message {
        Ok(job) => {
            // MutexGuard dropped here (end of let binding)
            job();  // lock is FREE -- other workers can recv()
        }
        Err(_) => break,
    }
}

The while let form keeps the MutexGuard alive through the entire job() execution because it's a temporary value in the while let expression. The let binding form drops the guard at the semicolon.

Using the Thread Pool ​

rust
use hello::ThreadPool;
use std::net::TcpListener;

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    for stream in listener.incoming() {
        let stream = stream.unwrap();

        pool.execute(|| {
            handle_connection(stream);
        });
    }
}

Now /sleep requests only block one worker thread. Other workers handle concurrent requests.

Phase 3: Graceful Shutdown ​

The Drop Implementation ​

rust
impl Drop for ThreadPool {
    fn drop(&mut self) {
        // Step 1: Close the channel by dropping the sender
        drop(self.sender.take());

        // Step 2: Wait for all workers to finish
        for worker in self.workers.drain(..) {
            println!("Shutting down worker {}", worker.id);

            if let Some(thread) = worker.thread.take() {
                thread.join().unwrap();
            }
        }
    }
}

Shutdown Sequence ​

Step by step:

  1. drop(self.sender.take()) -- moves the sender out of the Option and drops it, closing the channel
  2. Workers' recv() returns Err -- when the channel is closed, all pending recv() calls return Err
  3. Workers break out of their loops -- the Err arm in the match calls break
  4. thread.join() waits for each worker -- ensures all in-progress jobs complete

Why Option is Used ​

Both sender and thread are wrapped in Option so take() can move the value out during shutdown:

rust
// sender: Option<mpsc::Sender<Job>>
// Without Option, we can't move sender out of &mut self

// thread: Option<thread::JoinHandle<()>>
// Without Option, we can't move the JoinHandle to call join()

Testing Graceful Shutdown ​

Limit the server to a fixed number of requests:

rust
fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    let pool = ThreadPool::new(4);

    // Only handle 2 requests, then shut down
    for stream in listener.incoming().take(2) {
        let stream = stream.unwrap();
        pool.execute(|| {
            handle_connection(stream);
        });
    }

    println!("Shutting down.");
    // ThreadPool::drop() is called here automatically
}

Complete Project Structure ​

hello/
├── Cargo.toml
├── src/
│   ├── main.rs      # TCP listener + thread pool usage
│   └── lib.rs       # ThreadPool + Worker implementation
├── hello.html        # Success page
└── 404.html          # Not found page

Concepts Demonstrated ​

ConceptWhere Used
TCP networkingTcpListener, TcpStream
HTTP protocolRequest parsing, response formatting
Threadsthread::spawn in workers
Channelsmpsc::channel for job dispatch
MutexesMutex<Receiver> for shared access
ArcArc<Mutex<Receiver>> for shared ownership
ClosuresFnOnce() + Send + 'static as Job type
Trait objectsBox<dyn FnOnce()> for type erasure
Drop traitGraceful shutdown implementation
Optiontake() pattern for ownership transfer
Pattern matchingHTTP method routing
Error handlingunwrap() and match arms

Possible Extensions ​

Once the basic server works, consider these enhancements:

ExtensionKey Concepts
Add more routesPattern matching, modular handlers
Serve static filesfs::read, MIME types, Path
Connection keep-aliveLoop per connection, timeouts
Request body parsingContent-Length, POST handling
async/await rewritetokio::net::TcpListener, async tasks
Configuration fileserde, toml crate
Logginglog crate, env_logger
TLS supportrustls or native-tls crate

Built with VitePress | Rust SOP Documentation