A Simple Web Server
Build a minimal HTTP server using only Rust's standard library - understand TCP connections, HTTP protocol basics, and how to handle requests and send responses.
The web is built on servers that listen for requests and respond with data. Writing one yourself, without frameworks, strips away the magic and shows you exactly how the protocol works. Rust’s standard library gives you everything you need to create a TCP listener, accept connections, and talk HTTP — safely and without external crates.
What Is a Web Server?
A web server is a program that waits for incoming network connections, reads an HTTP request, and writes back an HTTP response. The core loop is straightforward: bind to a port, accept connections, handle each one. The interesting part happens inside the handler: parsing the request line and headers, deciding what the client wants, and constructing a proper response with a status code, headers, and body.
You interact with this process every time you open a browser. The browser opens a TCP connection to a server, sends something like GET / HTTP/1.1, and waits for the server to reply. Building that server from scratch makes the entire exchange concrete.
Why Build One in Rust?
Rust’s combination of memory safety and zero-cost abstractions means you can write low-level network code without worrying about buffer overflows, use-after-free, or data races. The compiler catches mistakes at build time, not at runtime when a malicious request hits your server.
A simple web server is also an ideal exercise in a tour of Rust. In a few dozen lines you encounter ownership (the TcpStream moves into the handler), error handling (match or ?), string handling (String::from_utf8_lossy), and pattern matching on request lines. It’s a compact, real-world use of the language.
A First Look at TCP and HTTP
Before writing code, it helps to know what you’re working with. When a client connects to your server, the operating system delivers a TcpStream — a bidirectional byte pipe. The client sends an HTTP request as plain text, and you respond with plain text that follows the HTTP specification.
An HTTP request starts with a request line, followed by headers, an empty line, and optionally a body. Here’s what your browser might send for a simple page load:
GET / HTTP/1.1
Host: localhost:7878
User-Agent: curl/8.5.0
Accept: */*
The first line contains the method (GET), the path (/), and the HTTP version. Each header is a key-value pair separated by a colon. The empty line signals the end of the headers. A POST request would also carry a body after that blank line.
The response follows a similar structure: a status line (HTTP/1.1 200 OK), headers like Content-Length and Content-Type, a blank line, and then the body.
Getting Your First Server Running
The steps are small but sequential — each one depends on the previous. Create a new project, replace the default code, run it, and test with a browser or curl.
Step 1: Create a new Rust project
Use Cargo to scaffold a binary project:
cargo new simple-server
cd simple-server
This gives you a Cargo.toml and an empty src/main.rs.
Step 2: Write the server code
Open src/main.rs and replace its contents with the following. It binds a TcpListener to port 7878, accepts connections in a loop, and for each one reads the request and sends back a simple HTML greeting.
use std::net::{TcpListener, TcpStream};
use std::io::{Read, Write};
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878")
.expect("Failed to bind to port 7878");
println!("Server listening on http://127.0.0.1:7878");
for stream in listener.incoming() {
match stream {
Ok(stream) => {
handle_connection(stream);
}
Err(e) => {
eprintln!("Connection failed: {}", e);
}
}
}
}
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
if let Ok(_) = stream.read(&mut buffer) {
let request = String::from_utf8_lossy(&buffer[..]);
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 32\r\n\r\n<h1>Hello from Rust!</h1>";
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
}
We’ll break down every line shortly. For now, this code is enough to see a response in the browser.
Step 3: Run the server
Start the server with:
cargo run
You should see the message Server listening on http://127.0.0.1:7878.
Step 4: Test with a browser or curl
Open http://localhost:7878 in your browser. You’ll see the greeting “Hello from Rust!” in a large heading.
From the terminal, you can use curl:
curl http://localhost:7878
The output will be the raw HTML.
It Works:
If you see the HTML response, your first Rust web server is running. Each new request prints nothing to the console yet — we’ll add logging next.
How the TCP Listener Works
The TcpListener::bind call asks the operating system for a socket on the given address and port. 127.0.0.1 means only connections from your own machine are accepted — nothing from the network. The method returns a Result, and .expect() panics with a clear message if something goes wrong (for example, if the port is already in use).
listener.incoming() returns an iterator that blocks until a new connection arrives. Each item is a Result<TcpStream, Error>. We match on it to separate successful connections from failed ones. On success, stream is a TcpStream — the communication channel to the client.
Don’t unwrap in production:
This tutorial uses .unwrap() and .expect() for brevity, but in real software they cause a panic that kills the server. Use proper error handling — return errors, log them, and keep the server running.
Reading the HTTP Request
The handle_connection function takes ownership of the TcpStream (that’s why it’s mut stream — we need to read and write). We create a fixed-size buffer of 1024 bytes and call stream.read(&mut buffer). This fills the buffer with whatever data the client sent, up to 1024 bytes.
String::from_utf8_lossy converts the raw bytes into a string, replacing any invalid UTF-8 sequences with a replacement character. This is safe for HTTP, which is ASCII‑compatible. The result is a Cow<str> that we can inspect.
Buffer size matters:
If a request exceeds 1024 bytes — which can happen with long URLs or many headers — only the first 1024 bytes are read, and the rest stays in the socket. That can corrupt subsequent reads. A production parser reads until the double CRLF (\r\n\r\n) that marks the end of the headers.
A typical printed request looks like:
GET / HTTP/1.1
Host: localhost:7878
User-Agent: curl/8.5.0
Accept: */*
The blank line at the end separates headers from the (absent) body.
Parsing the Request Line
For basic routing, you need the method and the path. The simplest approach is to grab the first line and split on whitespace:
let request_line = request.lines().next().unwrap_or("");
This assumes the first line exists. Then you can match on it to decide what to do. For a GET to the root path, the line is "GET / HTTP/1.1". A later section will turn this into a proper router.
Building and Sending an HTTP Response
A valid HTTP/1.1 response has three parts:
- Status line — the protocol version, a numeric status code, and a reason phrase (e.g.,
HTTP/1.1 200 OK). - Headers — each on its own line, ending with
\r\n. At a minimum, browsers expectContent-LengthandContent-Type. - Body — separated from the headers by an extra
\r\n.
In the initial example, the entire response is a hard‑coded string. stream.write_all(response.as_bytes()) sends the bytes to the client, and stream.flush() ensures they’re pushed out immediately.
CRLF line endings:
HTTP uses \r\n (carriage return + line feed) to separate lines. If you use plain \n, some clients will still work, but the specification requires \r\n. Always include it in status lines and headers.
Serving Static Files
Serving the same “Hello” string is fine, but a real server responds with different content for each path. Let’s serve an actual HTML file for the root route.
First, create index.html in the project directory (not inside src/):
\u003c!DOCTYPE html\u003e
\u003chtml lang="en"\u003e
\u003chead\u003e
\u003cmeta charset="UTF-8"\u003e
\u003ctitle\u003eRust Web Server\u003c/title\u003e
\u003c/head\u003e
\u003cbody\u003e
\u003ch1\u003eWelcome to Your Rust Web Server\u003c/h1\u003e
\u003cp\u003eThis page is being served by a web server built from scratch in Rust.\u003c/p\u003e
\u003c/body\u003e
\u003c/html\u003e
Now update handle_connection to read the file and include its contents in the response:
use std::fs;
fn handle_connection(mut stream: TcpStream) {
let mut buffer = [0; 1024];
if let Ok(_) = stream.read(&mut buffer) {
let request = String::from_utf8_lossy(&buffer[..]);
let request_line = request.lines().next().unwrap_or("");
if request_line == "GET / HTTP/1.1" {
match fs::read_to_string("index.html") {
Ok(contents) => {
let status_line = "HTTP/1.1 200 OK";
let response = format!(
"{}\r\nContent-Type: text/html\r\nContent-Length: {}\r\n\r\n{}",
status_line,
contents.len(),
contents
);
stream.write_all(response.as_bytes()).unwrap();
}
Err(_) => {
let response = "HTTP/1.1 500 Internal Server Error\r\n\r\nCould not read file";
stream.write_all(response.as_bytes()).unwrap();
}
}
} else {
let response = "HTTP/1.1 404 Not Found\r\n\r\nPage not found";
stream.write_all(response.as_bytes()).unwrap();
}
stream.flush().unwrap();
}
}
fs::read_to_string loads the file into a String. The Content-Length header is set to the length of that string in bytes. If the file can’t be read (maybe it was deleted), the server responds with a 500 Internal Server Error. For any path other than /, it returns a 404.
When you run the server and visit http://localhost:7878, the browser renders the HTML file. Check the Network tab in developer tools — you’ll see the full response with the correct status code and headers.
Basic Request Routing
With the request line in hand, you can build a simple router using a match expression. Each arm returns a response tuple: status, content type, and body.
let (status, content_type, body) = match request_line {
"GET / HTTP/1.1" => ("200 OK", "text/html", fs::read_to_string("index.html").unwrap()),
"GET /about HTTP/1.1" => ("200 OK", "text/html", "<h1>About</h1><p>Built with Rust.</p>".to_string()),
"GET /api/status HTTP/1.1" => ("200 OK", "application/json", r#"{"status":"running"}"#.to_string()),
_ => ("404 Not Found", "text/plain", "Not Found".to_string()),
};
let response = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nContent-Length: {}\r\n\r\n{}",
status,
content_type,
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
Now http://localhost:7878/about returns an about page, and http://localhost:7878/api/status returns a tiny JSON document. Any other path gets a 404.
Handling Multiple Clients
The server as written processes one request at a time. While it’s busy reading a file or waiting, no other client can connect. This is fine for learning, but real servers handle concurrency.
Rust gives you several ways to add parallelism. The simplest is to spawn a new operating system thread for each connection. A more scalable approach uses an async runtime like Tokio, but that’s outside the scope of this tour. The two approaches below show the single-threaded version side by side with a thread‑per‑connection model.
for stream in listener.incoming() {
match stream {
Ok(stream) => handle_connection(stream),
Err(e) => eprintln!("Connection failed: {}", e),
}
}
This is the original loop. Each handle_connection call blocks until the response is fully written. During that time, the loop cannot accept a new connection.
Beware of too many threads:
Threads have overhead — each one allocates stack space and the OS must schedule them. A sudden spike in connections could exhaust system resources. This example is for learning concurrency, not for building a high‑performance server.
Limitations of This Server
This server intentionally skips features that a production system needs. Understanding what’s missing helps you appreciate what frameworks like Actix‑web or Axum provide.
- No request body parsing — POST data is ignored.
- Fixed buffer — long requests are truncated.
- No URL decoding or query parameter extraction —
/search?q=rustis treated as a raw string. - Single-threaded by default — unless you add threads, one slow request blocks all others.
- No HTTPS — all data travels in plain text.
- No graceful shutdown — Ctrl+C kills the server instantly, even mid‑response.
Every one of these shortcomings is solvable, and building them yourself is a fantastic way to deepen your understanding of Rust and networking. But for real applications, use a battle‑tested framework and focus on your application logic.