A stdlib-only multithreaded HTTP web server written in Rust. It listens on a TCP socket, accepts concurrent client connections, parses HTTP/1.1 requests, and routes them to registered handlers. Work is dispatched through a reusable thread pool instead of spawning a new thread for every request.
The project is built up in six incremental commits, each one a layer of polish — error handling, real HTTP parsing, routing, generic handlers, tests, and ergonomic shutdown. Every piece is designed to be testable in isolation and to map to a Rust concept you can talk through in an interview.
rust-web-server/
├── Cargo.toml # Package manifest (no external dependencies)
├── hello.html # Static page served at GET /
├── 404.html # Legacy static page (the new 404 is built inline)
├── src/
│ ├── lib.rs # ThreadPool + Worker (named threads, explicit shutdown)
│ ├── main.rs # CLI args, TcpListener, dispatch to pool
│ ├── error.rs # ServerError + Result<T>
│ ├── http.rs # Request, Response, StatusCode, Method, parser
│ ├── handler.rs # handle_connection<R: Read, W: Write>
│ └── router.rs # Router with handler dispatch
└── README.md
| File | Role |
|---|---|
src/lib.rs |
Fixed-size ThreadPool with named workers, graceful shutdown, and a typed job queue. |
src/main.rs |
Reads port and pool_size from CLI, binds the listener, dispatches each connection. |
src/error.rs |
ServerError enum with Display, Error, and From<io::Error> so ? works everywhere. |
src/http.rs |
Request::parse, Response::write_to, Method, StatusCode. |
src/handler.rs |
handle_connection<R: Read, W: Write> — the HTTP logic has zero socket coupling. |
src/router.rs |
(Method, path) → handler dispatch, with 404 / 405 fallback. |
Prerequisites: Rust (2024 edition)
# Build
cargo build
# Run with defaults (127.0.0.1:7878, 4 workers)
cargo run
# Or override the port and pool size
cargo run 8080 8In 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
# 200 OK — health check
curl http://127.0.0.1:7878/health
# 404 NOT FOUND — unknown route
curl http://127.0.0.1:7878/unknown
# 405 METHOD NOT ALLOWED — known path, wrong method
curl -X POST http://127.0.0.1:7878/| Route | Method | Status | Response body |
|---|---|---|---|
/ |
GET | 200 | hello.html (embedded at compile time via include_str!) |
/sleep |
GET | 200 | hello.html after a 10 s sleep |
/health |
GET | 200 | ok (text/plain) |
| Anything else | GET | 404 | <h1>404 Not Found</h1> |
| Known path, wrong method | * | 405 | <h1>405 Method Not Allowed</h1> |
Client Main thread Thread pool (4 workers)
│ │ │
│ TCP connect │ │
├──────────────────────────────►│ TcpListener::accept │
│ │ pool.execute(handle_connection) │
│ ├──────────────────────────────────────►│
│ │ │ parse request
│ │ │ route by (Method, path)
│ │ │ build response
│ HTTP response │ │ write response
│◄───────────────────────────────┼───────────────────────────────────────┤
- The main thread binds
127.0.0.1:<port>and accepts connections. - Each accepted
TcpStreamis split viatry_clone, wrapped in a closure, and sent to the thread pool viaexecute. - A worker thread runs
handle_connection: parses the request, looks up the handler in the router, and writes the response.
ServerError is the single error type used in the request path. It implements Display, Error, and From<io::Error> so the ? operator works on any fallible I/O. No unwrap() in the request path.
Request::parse reads a real HTTP/1.1 request line and headers (via BufReader::read_line), capturing the method, path, and headers into a Request struct. Paths are trimmed; headers are lowercased.
Router uses function pointers (fn(&Request) -> Response) for zero-cost dispatch. Router::global() returns a &'static instance via OnceLock, so the dispatch is allocation-free per request. The router distinguishes 404 (no such path) from 405 (path exists for a different method).
handle_connection<R: Read, W: Write> decouples the HTTP logic from TcpStream. The same function runs against a real socket in main.rs and against a &[u8] + Vec<u8> in tests, with no network, no port, no flakiness. This is the move that separates learning code from production code.
ThreadPool::new spawns size workers named worker-0, worker-1, …, visible in htop and ps -L. The Drop impl drops the Sender, lets workers' recv() return Err, and joins them sequentially. ThreadPool::shutdown is the explicit, idempotent version of the same logic.
mpsc::channel— multi-producer, single-consumer queue fromstd::sync::mpsc.Arc<Mutex<Receiver<Job>>>—mpsc::ReceiverisSendbut notSync, so we wrap it inMutexto share across workers.Arcprovides shared ownership;Rcwould not cross thread boundaries.Box<dyn FnOnce() + Send + 'static>— type-erased closure.Sendbecause the job moves to a worker thread;'staticbecause the job may live in the queue indefinitely.- The
MutexGuardis dropped at the end of thereceiver.lock().unwrap().recv()statement —job()runs outside the lock, so workers don't serialize.
Every public type has unit tests against &[u8] + Vec<u8> (no sockets). Public functions have /// doc comments with runnable examples — cargo test --doc proves them.
The repository history is intentionally a curriculum. Reading it in order tells six stories:
add ServerError type, propagate Result in main— replaceunwrap()with?and a single error type.add http parser and response types— replace byte-prefix matching with a real HTTP/1.1 parser.add router with handler dispatch— replace the if/else routing chain with a static route table.extract generic handle_connection into handler.rs— make the HTTP logic generic overR: Read, W: Writeso it can be tested without a socket.add unit tests, doc comments, and 405 Method Not Allowed— add 404/405 distinction, tests, and runnable doc examples.named worker threads, CLI args, explicit shutdown— name the workers (worker-0…), exposeThreadPool::shutdown, readportandpool_sizefrom CLI args.
This is an educational, minimal HTTP server — not production-ready:
- No TLS/HTTPS
- No HTTP/1.1 keep-alive or pipelining (
Connection: closeon every response) - No POST body parsing
- No query string, no MIME sniffing beyond a static
Content-Type - Single-process; no async runtime (Tokio, etc.)
- No bounded queue (unbounded
mpsc::channel)
These constraints keep the focus on sockets, HTTP basics, ownership, and concurrency — the same fundamentals the project highlights.
See repository settings for license information.