26.1A single-threaded web server

Last updated June 13, 2026

Here we are: the finale. Over twenty-five chapters you've built up every tool Rust offers, and this chapter spends them on one real program, a working web server you can open in a browser. We'll build it the way this whole course has worked: in small steps, compiling at every stage, with the wrong-then-right honesty you've come to expect. By the end of the chapter it will handle many requests at once through a thread pool you build yourself. This lesson gets the simplest possible version running: one connection at a time, HTTP parsed by hand. No web framework, no dependencies, just the standard library and what you already know.

A note on scope: a real production server uses a framework (like axum or actix) and async (chapter 23). We're building from raw sockets on purpose, because the point is to see how the pieces actually work, and to exercise the language, not to compete with nginx.

Listening for connections

The web runs on TCP, the protocol underneath HTTP that carries a stream of bytes between two machines. The standard library's TcpListener binds to a port and waits for incoming connections:

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();
        println!("Connection established!");
    }
}

TcpListener::bind("127.0.0.1:7878") claims port 7878 on your own machine (127.0.0.1 is "this computer"; 7878 is just a free port, it spells "rust" on a phone keypad). bind returns a Result because binding can fail, the port might be in use, so we unwrap. Then listener.incoming() gives an iterator (chapter 19) over connection attempts; each stream is a Result<TcpStream> representing one client's connection. Run this, visit http://127.0.0.1:7878 in a browser, and you'll see "Connection established!" print, maybe a few times, since browsers open several connections. The browser will report an error, because we haven't answered yet. That's next.

Reading the request

A connection is a two-way TcpStream. The browser sends an HTTP request, a block of text, and waits for a response. Let's read and look at the request. HTTP is line-based text, so we wrap the stream in a BufReader and read lines (lesson 20.2):

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

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

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

fn handle_connection(stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);
    let http_request: Vec<String> = buf_reader
        .lines()
        .map(|result| result.unwrap())
        .take_while(|line| !line.is_empty())
        .collect();

    println!("Request: {http_request:#?}");
}

That .lines().map(...).take_while(...).collect() is a chapter-19 pipeline doing real work: read each line, unwrap it, keep taking lines until a blank one (an HTTP request's headers end with an empty line), and collect them into a Vec<String>. Visit the page again and the terminal prints the request, whose first line looks like:

Request: [
    "GET / HTTP/1.1",
    "Host: 127.0.0.1:7878",
    "User-Agent: ...",
    ...,
]

The first line, GET / HTTP/1.1, is the request line: the method (GET), the path (/), and the HTTP version. That's all we need to decide what to send back.

Writing a response

An HTTP response is also just text in a fixed shape: a status line, then headers, then a blank line, then the body. The minimal valid response is:

HTTP/1.1 200 OK\r\n\r\n

200 OK means success; \r\n is HTTP's line ending (carriage-return + newline); the doubled \r\n\r\n is the blank line separating headers from the (here empty) body. Let's send a real HTML page. Create a file hello.html next to your program:

<!DOCTYPE html>
<html>
  <head><title>Hello</title></head>
  <body><h1>Hello from Rust!</h1></body>
</html>

And write it back as the response body:

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

fn main() {
    let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
    for stream in listener.incoming() {
        handle_connection(stream.unwrap());
    }
}

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();
}

Now there's real routing. We read just the request line, and if it's a request for /, we serve hello.html with 200 OK; otherwise we serve a 404.html page with 404 NOT FOUND (create a similar 404.html with a "not found" message). fs::read_to_string (chapter 20) loads the file, we build the response string with format! (the status line, a Content-Length header so the browser knows the body size, the blank line, then the contents), and stream.write_all sends it as bytes. Visit http://127.0.0.1:7878/ and your browser shows "Hello from Rust!"; visit any other path and you get the 404 page. You've written a web server.

What you just built, with no new tools

Look back at the ingredients: a TcpListener and TcpStream from the standard library, a BufReader and fs::read_to_string from chapter 20, an iterator pipeline from chapter 19, format! and string handling from chapter 5, Result and unwrap from chapter 12. A working HTTP server is almost entirely things you already knew, plus two networking types. That's the point of a capstone: the language you've learned is enough to build real systems, and the "magic" of a web server turns out to be reading text off a socket and writing text back.

The flaw: one at a time

This server has one serious problem, and the rest of the chapter exists to fix it. It handles connections strictly one at a time: handle_connection runs to completion before the for loop accepts the next connection. If one request is slow, every other visitor waits in line behind it. We can prove it by making one route deliberately slow:

// inside handle_connection, add a slow route:
use std::thread;
use std::time::Duration;

// "GET /sleep HTTP/1.1" => sleep 5 seconds, then serve hello.html

With a /sleep route that calls thread::sleep(Duration::from_secs(5)) before responding, open /sleep in one browser tab and / in another immediately after: the fast page hangs for five seconds, blocked behind the slow one. A server that can only do one thing at a time is barely a server. The fix is concurrency, exactly the chapter-22 material, and the next lesson begins turning this single-threaded server into one that handles many connections at once.

Quiz time

Question #1

What does TcpListener::bind("127.0.0.1:7878") do, and what does listener.incoming() give you?

Show solution

bind claims port 7878 on the local machine (127.0.0.1) so the program can accept TCP connections there; it returns a Result (binding can fail if the port's taken). listener.incoming() returns an iterator over incoming connection attempts; each item is a Result<TcpStream>, one client connection you can read the request from and write the response to.

Question #2

An HTTP response has a fixed shape. What are its parts, in order?

Show solution

A status line (e.g. HTTP/1.1 200 OK), then headers (each ending in \r\n, e.g. Content-Length: 42), then a blank line (\r\n by itself) separating headers from the body, then the body (e.g. the HTML). The minimal valid response is just HTTP/1.1 200 OK\r\n\r\n. The request has a similar shape, starting with a request line like GET / HTTP/1.1.

Question #3

What is the key limitation of this single-threaded server, and how would you demonstrate it?

Show solution

It handles connections strictly one at a time: handle_connection must finish before the next connection is accepted, so a slow request blocks all others. You demonstrate it with a route that sleeps (e.g. /sleep waiting 5 seconds): request /sleep in one tab and / in another right after, and the fast page is stuck waiting behind the slow one. The fix is to handle connections concurrently.

The server works but serves one visitor at a time. The next lesson (26.2) confronts the concurrency problem directly: the naive "spawn a thread per request" fix, why it doesn't scale, and the design conversation that leads to a thread pool.