How to Build a Browser from Scratch in Rust: A Step-by-Step Guide

Tejas More

27 May, 2025

Have you ever wondered what happens behind the scenes when you type a URL and press Enter? In this guide, we’ll walk through how to build a basic web browser from scratch using Rust—one module at a time. This isn’t a toy project; it's a practical exercise in understanding how browsers work under the hood.

Why Rust?

Rust is fast, memory-safe, and built for systems-level development. Its compile-time guarantees and performance make it an excellent choice for building something as low-level and resource-intensive as a browser. Mozilla’s experimental browser engine Servo was written in Rust for the same reasons.


Prerequisites

Before getting started, ensure you have:

  • A basic understanding of Rust (ownership, enums, structs, modules)
  • Familiarity with how the web works (HTTP, HTML, CSS)
  • The Rust toolchain installed (rustup, cargo)

Install Rust if you haven’t already:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh


High-Level Architecture

A modern browser has several key components:

  1. Networking Layer – Fetches web pages
  1. HTML Parser – Builds a DOM tree from the HTML
  1. CSS Parser – Parses and applies styles
  1. Render Tree Constructor – Maps DOM with styles
  1. Layout Engine – Calculates position and size of elements
  1. Painter – Renders elements to a pixel buffer
  1. Event Loop – Handles input and reflow

We’ll build a simplified version of each in this guide.


Step 1: Set Up the Project

Create a new Rust project:

cargo new rust_browser

cd rust_browser

In src/main.rs:

fn main() {

println!("Starting Rust browser...");

}

We'll build the browser in stages, using separate modules for each component.


Step 2: Fetching HTML

Use the reqwest crate for HTTP requests.

In Cargo.toml:

[dependencies]

reqwest = { version = "0.11", features = ["blocking"] }

In main.rs:

use reqwest;

fn fetch_url(url: &str) -> Result<String, reqwest::Error> {

let response = reqwest::blocking::get(url)?;

Ok(response.text()?)

}

This function fetches the HTML content of a URL using a blocking HTTP client.


Step 3: Parsing HTML

Create a dom.rs module. Here’s a basic structure for parsing tags:

#[derive(Debug)]

pub struct Node {

pub tag: String,

pub children: Vec<Node>,

}

pub fn parse_html(input: &str) -> Node {

// This is where you would tokenize and recursively parse the HTML

Node {

tag: "html".to_string(),

children: vec![],

}

}

For real parsing, consider integrating the html5ever crate used in the Servo project.


Step 4: Parsing CSS

Create a css.rs module:

#[derive(Debug)]

pub struct Stylesheet {

pub rules: Vec<Rule>,

}

#[derive(Debug)]

pub struct Rule {

pub selector: String,

pub declarations: Vec<(String, String)>,

}

pub fn parse_css(input: &str) -> Stylesheet {

Stylesheet { rules: vec![] }

}

This placeholder represents a very basic CSS parser. You can expand it to support selectors, cascading rules, and more.


Step 5: Constructing the Render Tree

Now merge DOM nodes with CSS rules to create a render tree.

pub struct RenderNode {

pub tag: String,

pub styles: Vec<(String, String)>,

pub children: Vec<RenderNode>,

}

pub fn buildrendertree(dom: &Node, stylesheet: &Stylesheet) -> RenderNode {

RenderNode {

tag: dom.tag.clone(),

styles: vec![], // Here you would apply matched styles

children: vec![],

}

}


Step 6: Layout Engine

Calculate the size and position of each box on the screen.

pub struct LayoutBox {

pub width: u32,

pub height: u32,

pub x: u32,

pub y: u32,

pub children: Vec<LayoutBox>,

}

pub fn layout(rendernode: &RenderNode, parentx: u32, parent_y: u32) -> LayoutBox {

LayoutBox {

width: 800,

height: 20,

x: parent_x,

y: parent_y,

children: vec![],

}

}

For now, this function uses static values. In a full implementation, you would interpret CSS rules to compute widths, margins, and positioning.


Step 7: Painting

We need to draw our layout boxes to the screen. The pixels crate works well with winit for windowing.

Add these to Cargo.toml:

pixels = "0.11"

winit = "0.28"

Inside your paint loop:

for layoutbox in layouttree {

draw_rect(x, y, width, height, color);

}

This pseudocode draws colored rectangles based on layout properties. Integrate pixels to actually render them.


Step 8: Input and Navigation

With winit, you can handle events such as mouse clicks or key presses. This allows you to add features like:

  • A basic address bar
  • Reloading pages
  • Click interactions and scrolling

You can also build a minimal history manager for back/forward navigation.


Optional Features

Here are some ideas to take the browser further:

  • Implement HTTPS with rustls
  • Add a JavaScript engine using boa
  • Support images and fonts
  • Implement multi-tab support with threads
  • Build a GUI using egui, iced, or dioxus

Conclusion

Building a browser from scratch is an excellent way to learn systems programming, rendering pipelines, and web standards. With Rust, you get both safety and performance, making it an ideal choice for the task.

This guide covered the essential components: networking, parsing, rendering, and layout. Each could be expanded into a full project on its own.

If you're ready for the next step, consider contributing to or studying the Servo project. It's a production-grade Rust browser engine that explores many of the concepts covered here in depth.


Additional Resources