Building an HTTP server from scratch
You've built APIs in Python or Node where the framework handles the socket, the parsing, and the routing. You send a request, you get a response. The framework hides the network stack. But what happens when you strip away the framework? What does it look like to talk directly to the operating system?
You write a few lines of Rust, bind a port, and suddenly you're speaking HTTP by hand. No magic. Just bytes flowing over a TCP connection. This approach uses only the standard library. It forces you to understand the protocol, the socket lifecycle, and how Rust manages ownership across threads.
HTTP is just text over a pipe
HTTP is a text-based protocol. TCP is a reliable byte stream. Think of TCP as a waiter in a restaurant. The waiter ensures the order gets from the table to the kitchen without dropping words. HTTP is the specific script the waiter follows. "Order for table 1, one burger, no pickles."
If you skip the script, the kitchen doesn't know what to do. If you send "one burger" without the status line and headers, the client waits forever. Rust's standard library gives you the waiter. You have to write the script.
The script has rules. The response must start with a status line like HTTP/1.1 200 OK. Then headers. Then a blank line \r\n\r\n. Then the body. If you miss the blank line, the browser assumes headers are still coming and hangs. The protocol is a contract. Follow it exactly.
Minimal server example
This server binds to localhost, accepts one connection, sends a response, and exits. It demonstrates the core loop and the response format.
use std::net::TcpListener;
use std::io::{Write, BufRead, BufReader};
/// Binds a listener and handles a single connection.
fn main() {
// Bind to localhost on port 7878.
// unwrap() panics if the port is taken or the address is invalid.
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
// incoming() returns an iterator over connections.
// This loop blocks until a new client connects.
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
/// Sends a static HTTP response to the client.
fn handle_connection(mut stream: std::net::TcpStream) {
// HTTP response must start with a status line and headers.
// \r\n\r\n separates headers from the body.
let response = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nHello from Rust!";
// write_all ensures the entire buffer is sent to the socket.
// write() might send only part of the data.
stream.write_all(response.as_bytes()).unwrap();
// flush() forces the OS to send buffered data immediately.
stream.flush().unwrap();
}
Run this code. Open a browser to http://127.0.0.1:7878. You see "Hello from Rust!". The server handles one request and keeps the loop open for the next.
Send the bytes. Flush the buffer. The browser doesn't care about your variables.
What happens under the hood
TcpListener::bind asks the OS to create a socket and reserve port 7878 on the loopback interface. The OS returns a file descriptor wrapped in TcpListener.
listener.incoming() returns an iterator. Calling next() on the iterator blocks the thread until a client connects. When a browser hits the URL, the OS accepts the connection and hands you a TcpStream. This stream represents the open channel to the client.
Inside handle_connection, you construct a string. write_all converts that string to bytes and pushes them into the OS socket buffer. flush tells the OS to transmit the buffer over the network.
The browser receives the bytes. It parses the status line. It sees 200 OK. It parses the headers. It sees Content-Type: text/plain. It finds the blank line. It reads the body. It renders the text.
If you forget flush, the data might sit in the buffer. The browser waits. The connection times out. Always flush after writing.
Reading requests and routing
A real server reads the request. It inspects the path. It routes to different handlers. This example reads the request line and echoes it back. It also shows how to use BufReader for efficient line reading.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
/// Handles a connection by reading the request and sending a dynamic response.
fn handle_connection(mut stream: TcpStream) {
// Wrap the stream in a BufReader for efficient line-by-line reading.
// Reading byte-by-byte is slow due to system calls.
let reader = BufReader::new(&stream);
// Read the request line (e.g., "GET / HTTP/1.1").
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
println!("Request: {}", request_line.trim());
// Construct a response with Content-Length.
// Browsers need Content-Length or Transfer-Encoding to know when the body ends.
let body = "Hello, World!";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{}",
body.len(),
body
);
// write_all ensures the entire buffer is sent.
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
handle_connection(stream);
}
}
BufReader buffers input. read_line reads until a newline. This avoids repeated system calls. Without buffering, every byte read triggers a kernel transition. Performance tanks.
The response includes Content-Length. This tells the browser exactly how many bytes to expect. If you omit it, the browser might wait for more data. Some browsers handle missing length by closing the connection, but others hang. Send the length.
Read the request. Respond with structure. The protocol is a contract, not a suggestion.
Handling concurrency with threads
The examples so far handle one connection at a time. If a client connects and holds the connection open, the server stops accepting new connections. The main thread is stuck in handle_connection.
Rust makes it easy to spawn threads. Each connection gets its own thread. The main loop accepts connections immediately. This is a common pattern for simple servers.
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::thread;
/// Handles a connection in a separate thread.
fn handle_connection(mut stream: TcpStream) {
let reader = BufReader::new(&stream);
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
let response = "HTTP/1.1 200 OK\r\nContent-Length: 11\r\n\r\nHello, World!";
stream.write_all(response.as_bytes()).unwrap();
stream.flush().unwrap();
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:7878").unwrap();
for stream in listener.incoming() {
let stream = stream.unwrap();
// Spawn a thread to handle the connection.
// move transfers ownership of stream to the closure.
thread::spawn(move || {
handle_connection(stream);
});
}
}
The move keyword is required here. The closure captures stream by value. The thread needs to own the stream because the thread might outlive the loop iteration. Without move, the closure tries to borrow stream. The compiler rejects this because the thread could access the stream after the loop variable is dropped.
If you try to access stream after passing it to thread::spawn, the compiler rejects you with E0382 (use of moved value). The thread owns the stream now. You can't touch it.
This pattern scales for small workloads. Each thread consumes OS resources. Under heavy load, thousands of threads cause context switching overhead. Production servers use async runtimes like Tokio. But for learning, threads are clear and effective.
Spawn threads for concurrency. Accept connections in the main loop.
Pitfalls and compiler errors
Building a server from scratch exposes raw network behavior. Small mistakes break clients.
If you pass a String to write_all directly, the compiler rejects it with E0277 (the trait Write is not implemented for String). Sockets speak bytes, not UTF-8 strings. You must convert using .as_bytes() or .into_bytes().
If you forget \r\n\r\n in the response, the client waits for headers. The connection hangs. HTTP parsers are strict. The blank line is mandatory.
If you use write instead of write_all, you risk partial writes. write returns the number of bytes sent. It might send only part of the buffer. The client receives incomplete data. write_all loops internally until everything is sent. Use write_all for sockets.
If you bind to 127.0.0.1, the server is only accessible from the local machine. Change to 0.0.0.0 to expose the server to the network. This matters when deploying to a VM or container. The convention in production code is to read the address from an environment variable. Hardcoding network topology makes deployment brittle.
Check your headers. Verify your bytes. The network will punish laziness.
Conventions and community norms
Rust developers follow specific patterns for network code.
Use expect instead of unwrap in main. TcpListener::bind("127.0.0.1:7878").expect("Failed to bind to port") provides a clear error message when the server crashes. unwrap gives a generic panic. expect helps debugging.
Keep unsafe out of socket code. The standard library handles sockets safely. You don't need raw pointers or manual memory management. If you see unsafe in a simple server, something is wrong.
Name threads when debugging. thread::Builder::new().name("worker".into()).spawn(...) makes stack traces readable. This is optional but helpful in multi-threaded apps.
Flush after every write. Buffers are optimized for throughput, not latency. If you don't flush, data sits in memory. Clients time out. Flush is the signal that the message is complete.
Bind to 0.0.0.0 when you ship. Bind to 127.0.0.1 when you test. Security starts with the address.
When to use raw sockets vs frameworks
Raw sockets teach you the protocol. They give you absolute control. They also require you to implement routing, parsing, TLS, and concurrency yourself. Choose based on your goal.
Use a raw TcpListener when you are learning how HTTP works or building a highly specialized protocol that doesn't fit standard frameworks.
Use a framework like Axum or Actix when you need routing, middleware, JSON serialization, and async concurrency out of the box.
Use hyper when you need a low-level HTTP engine with async support but want to build your own routing logic.
Reach for std::net when performance profiling shows the framework overhead is the bottleneck and you need absolute control over the socket lifecycle.
Pick the tool that matches the complexity. Don't build a framework to say hello.