A multithreaded HTTP web server written in Rust from first principles. It listens on a TCP socket, accepts concurrent client connections, parses minimal HTTP GET requests, and serves static HTML files with well-formed HTTP responses. Work is dispatched through a reusable thread pool instead of spawning a new thread for every request.
This project follows the patterns from The Rust Programming Language, Chapter 20, extended into a small library + binary crate layout.
rust-web-server/
├── Cargo.toml # Package manifest (no external dependencies)
├── hello.html # Static page served at GET /
├── 404.html # Static page served for unknown routes
├── src/
│ ├── lib.rs # Reusable ThreadPool implementation
│ └── main.rs # TCP listener, HTTP handling, server entry point
└── README.md
| File | Role |
|---|---|
src/main.rs |
Binds a TCP listener, accepts connections, and delegates each request to the thread pool |
src/lib.rs |
Generic thread pool: fixed worker threads, job queue, graceful shutdown |
hello.html / 404.html |
Static content read from disk and embedded in HTTP responses |
Prerequisites: Rust (2024 edition)
# Build
cargo build
# Run the server (listens on 127.0.0.1:7878)
cargo runIn another terminal:
# 200 OK — serves hello.html
curl http://127.0.0.1:7878/
# 200 OK — intentionally slow (10 s); useful for observing thread-pool concurrency
curl http://127.0.0.1:7878/sleep
# 404 NOT FOUND — serves 404.html
curl http://127.0.0.1:7878/unknownNote:
main.rscurrently accepts only two connections (listener.incoming().take(2)) before shutting down. Remove.take(2)for a long-running server.
Client Main thread Thread pool (4 workers)
│ │ │
│ TCP connect │ │
├───────────────────────────►│ TcpListener::accept │
│ │ pool.execute(handle_connection) │
│ ├─────────────────────────────────────►│
│ │ │ read request
│ │ │ match route
│ │ │ read HTML file
│ HTTP response │ │ write response
│◄───────────────────────────┼──────────────────────────────────────┤
- The main thread binds
127.0.0.1:7878and blocks on incoming TCP connections. - Each accepted
TcpStreamis wrapped in a closure and sent to the thread pool viaexecute. - A worker thread runs
handle_connection: reads bytes from the socket, interprets the request line, loads the matching HTML file, and writes a complete HTTP response back.
The server is built entirely on Rust’s standard library networking and I/O primitives—no HTTP framework.
| Concept | Where in code | What it does |
|---|---|---|
| TCP listening socket | TcpListener::bind("127.0.0.1:7878") in main.rs |
Creates a bound socket and listens for inbound connections |
| Accepting clients | for stream in listener.incoming() in main.rs |
Iterates over incoming TcpStream handles, one per client |
| Reading the request | stream.read(&mut buffer) in handle_connection |
Reads raw bytes from the socket into a fixed buffer |
| Parsing HTTP | buffer.starts_with(get) / starts_with(sleep) in handle_connection |
Matches the HTTP request line (GET / HTTP/1.1, etc.) |
| Serving static content | fs::read_to_string(filename) in handle_connection |
Loads hello.html or 404.html from disk |
| Valid HTTP response | format!("{}\r\nContent-Length: {}\r\n\r\n{}", ...) + stream.write_all |
Builds status line, Content-Length header, blank line, and body; writes to the socket |
// main.rs — bind, accept, and dispatch
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
let pool = ThreadPool::new(4);
for stream in listener.incoming().take(2) {
let stream = stream.unwrap();
pool.execute(|| {
handle_connection(stream);
});
}// main.rs — request parsing and response generation
let (status_line, filename) = if buffer.starts_with(get) {
("HTTP/1.1 200 OK", "hello.html")
} else if buffer.starts_with(sleep) {
thread::sleep(Duration::from_secs(10));
("HTTP/1.1 200 OK", "hello.html")
} else {
("HTTP/1.1 404 NOT FOUND", "404.html")
};
let response = format!(
"{}\r\nContent-Length: {}\r\n\r\n{}",
status_line,
contents.len(),
contents
);
stream.write_all(response.as_bytes()).unwrap();The /sleep route deliberately blocks for 10 seconds. With the thread pool, other requests can still be processed by idle workers—demonstrating concurrent connection handling without blocking the accept loop on slow clients.
Instead of thread::spawn for every connection, the server reuses a fixed set of worker threads. This reduces thread creation/teardown overhead and caps resource usage under load.
| Concept | Where in code | What it does |
|---|---|---|
| Pool construction | ThreadPool::new(4) in main.rs |
Spawns 4 long-lived worker threads at startup |
| Job submission | pool.execute(|| { ... }) in main.rs |
Sends a closure (the “job”) to an idle worker |
| Job queue | mpsc::channel() in lib.rs |
Channel carries Box<dyn FnOnce() + Send> jobs from producers to workers |
| Shared receiver | Arc<Mutex<mpsc::Receiver<Job>>> in lib.rs |
All workers share one receiver safely |
| Worker loop | Worker::new in lib.rs |
Each worker blocks on recv(), runs the job, then waits for the next |
| Graceful shutdown | impl Drop for ThreadPool in lib.rs |
Drops the sender, workers exit their loops, join() waits for clean termination |
// lib.rs — enqueue a job without spawning a new thread
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();
}// lib.rs — worker threads pull jobs from the shared channel
let thread = thread::spawn(move || loop {
let message = receiver.lock().unwrap().recv();
match message {
Ok(job) => job(),
Err(_) => break, // channel closed → shutdown
}
});Thread-per-request vs. thread pool: spawning a thread per connection works for small demos but scales poorly—each thread consumes stack memory and kernel bookkeeping. A pool amortizes creation cost and bounds concurrency to size workers, improving scalability for many simultaneous clients.
| Concept | Illustration in this codebase |
|---|---|
| Network programming | std::net::{TcpListener, TcpStream} — low-level TCP server/client streams without a web framework |
| Socket communication | read, write_all, and flush on TcpStream — byte-oriented bidirectional I/O over the network |
| HTTP protocol | Manual request-line matching and response assembly (status line, headers, \r\n\r\n separator, body) |
| Ownership | stream is moved into the execute closure (FnOnce); jobs are owned Box<dyn FnOnce()>; Arc shares the receiver across workers without copying it |
| Concurrency | Multiple OS threads (thread::spawn), synchronized access via Mutex, message passing via mpsc, and JoinHandle for joining workers on shutdown |
| Performance / resource control | Fixed pool size limits threads; channel decouples the accept loop from request handling; /sleep route shows non-blocking concurrency across workers |
Rust’s type system enforces thread safety at compile time:
Sendon closures ensures captured data can be transferred to worker threads.'staticon job closures prevents use-after-free across thread lifetimes.Arc<Mutex<Receiver>>provides shared, mutable access to the job queue without data races.
| Route | Status | Response body | Notes |
|---|---|---|---|
GET / |
200 OK | hello.html |
Default landing page |
GET /sleep |
200 OK | hello.html |
Sleeps 10 s before responding (concurrency demo) |
| Anything else | 404 NOT FOUND | 404.html |
Unknown paths |
Only the request line is parsed; headers and request bodies are ignored.
This is an educational, minimal HTTP server—not production-ready:
- No TLS/HTTPS
- No HTTP/1.1 keep-alive or pipelining
- No POST, PUT, or other methods
- No query strings, routing framework, or MIME type detection
- Single-process; no async runtime (Tokio, etc.)
- Request buffer fixed at 1024 bytes
These constraints keep the focus on sockets, HTTP basics, ownership, and concurrency—the same fundamentals highlighted in the project goals above.
See repository settings for license information.