442 words
2 minutes
Building a Web Server from Scratch in Rust

We often rely on frameworks like Actix and Rocket to build web applications, but do you know what is happening under the hood? In this post, we’ll strip away the abstractions and build a functional web server from scratch in Rust to understand the foundations of HTTP and TCP.

The Networking Stack (OSI Model)#

To understand web servers, we first need to look at the OSI Model, which breaks computer networking into seven layers. For a web server, the two most critical layers are the Transport Layer (Layer 4), which manages the actual delivery of data using protocols like TCP, and the Application Layer (Layer 7), where protocols like HTTP define how the client and server communicate.

Setting up a TCP Listener#

The core of every web server is a TCP Listener. We use Rust’s standard library to bind to a port and listen for incoming connections. For each accepted connection, we spawn a new thread to handle it concurrently so the main loop remains free to accept the next client.

use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:8080").unwrap();
for stream in listener.incoming() {
match stream {
Ok(stream) => {
std::thread::spawn(move || handle_client(stream));
}
Err(e) => println!("Error: {}", e),
}
}
}

Handling Client Requests#

When a client connects, we read the incoming byte stream into a buffer and then parse it into a structured Request type. This struct captures the HTTP method (e.g., GET, POST), the requested URI, the HTTP version, any headers, and an optional body. From there we can inspect the request and generate an appropriate Response.

pub struct Request {
pub method: HttpMethod,
pub uri: String,
pub version: String,
pub headers: HashMap<String, String>,
pub body: Option<String>,
}

Routing and Middleware#

To make our server useful, we need a way to map specific URL paths to handler functions. We achieve this with a HashMap of routes keyed by path and HTTP method. We can also define a Middleware trait to intercept requests and responses — useful for cross-cutting concerns like logging or authentication without cluttering individual route handlers.

pub trait Middleware: Send + Sync {
fn on_request(&self, request: Request) -> FutureRequest;
fn on_response(&self, response: Response) -> FutureResponse;
}

Running the Server#

Finally, we instantiate our server, bind it to an address, and register our routes. For handling multiple concurrent connections efficiently, we can swap raw threads for Tokio — Rust’s async runtime — which gives us non-blocking I/O without the overhead of spawning a thread per connection.

#[tokio::main]
async fn main() {
let addr = SocketAddr::from(([127, 0, 0, 1], 8080));
let server = ServerBuilder::new()
.bind(addr)
.route("/", HttpMethod::GET, hello_handler)
.build();
server.run().await.unwrap();
}

Watch the full step-by-step video tutorial below:

YouTube#

Building a Web Server from Scratch in Rust
https://fuwari.vercel.app/posts/server/
Author
Hashi Warsame
Published at
2024-07-08
License
CC BY-NC-SA 4.0