---
title: "26.1 A single-threaded web server"
description: "Building a working web server from scratch: listening on a TCP port, reading an HTTP request, and writing an HTTP response by hand."
url: "https://learnrust.net/chapter-26/single-threaded-web-server/"
last_updated: "2026-06-13"
---

# 26.1 A single-threaded web server

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:

```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();
        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](https://learnrust.net/chapter-20/reading-and-writing-files/)):

```rust
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:

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

And write it back as the response body:

```rust
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:

```rust
// 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?

<details class="solution">
<summary>Show solution</summary>

`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.

</details>

**Question #2**

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

<details class="solution">
<summary>Show solution</summary>

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`.

</details>

**Question #3**

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

<details class="solution">
<summary>Show solution</summary>

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.

</details>

The server works but serves one visitor at a time. The next lesson ([26.2](https://learnrust.net/chapter-26/from-single-to-multithreaded/)) 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.

## Sitemap

See the full [sitemap](https://learnrust.net/sitemap.md) for all pages.
