Build a Mini Web Browser in Python

Build a working web browser from scratch using raw TCP sockets and Tkinter.

Introduction

30 Second Summary

You use a web browser every single day. But have you ever wondered what actually happens between typing a URL and seeing a web page appear on your screen?

In this project, you will build a mini web browser from scratch in Python. Your browser will fetch real web pages from the internet, strip out HTML tags, and display the text in a scrollable window.

What You'll Build

A Python program that implements the same core pipeline used by Chrome, Firefox, and Safari: network fetch, HTML parse, layout, and paint.

By the end of this project, you'll have:

  • A URL class that connects to web servers over raw TCP sockets and fetches pages using HTTP.
  • A lex function and layout function that strip HTML tags and compute word positions with line wrapping.
  • A Browser class that renders page text in a scrollable Tkinter window with keyboard scrolling.
  • Secret Mission: Add bold and italic text rendering by building an HTML tokenizer and a Layout class that switches fonts.

Are there any prerequisites?

You should have basic Python experience with variables, functions, classes, and loops. No web development or networking knowledge is needed.

Before We Start

You're about to build a web browser from the ground up in Python. Before writing any code, you need two things: Python 3 and a way to draw graphics on screen.

Python 3 ships with a built-in graphics library called tkinter. In this step, you'll install Python 3.14, verify that tkinter works, and create your project folder.

In this step, get ready to:
  • Install Python 3.14 and verify it in your terminal.
  • Confirm that tkinter can create a graphical window.
  • Set up a mini-browser project folder with an empty browser.py file.
Download and install Python 3.14

Your machine has Python 2 installed, but this project needs Python 3. Python 2 reached end of life in 2020, so you'll install a fresh copy of Python 3.14 from the official website.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check if Python 3 is already installed by running this command:
python3 --version

✔️ I see version 3.14 or higher

You should see output like Python 3.14.6. Python 3.14 is ready to go.

ⓧ I see an older version

You have an older version of Python 3 installed. macOS often ships with an older Python 3 via Xcode Command Line Tools. You need Python 3.14 for this project.

  • Open https://www.python.org/downloads/ in your browser.
  • Click the Download Python 3.14.6 button.
  • Run the downloaded installer and follow the prompts. The installer requires administrator privileges.
  • On macOS, double-click Install Certificates.command in the /Applications/Python 3.14/ folder when the installer finishes. This installs SSL certificates that Python needs for HTTPS connections.
  • Close and reopen your terminal, then run the version check again:
python3 --version

You should now see Python 3.14.6 or higher.

ⓧ Command not found

Python 3 is not installed yet. You'll download it from the official website.

  • Open https://www.python.org/downloads/ in your browser.
  • Click the Download Python 3.14.6 button.
  • Run the downloaded installer and follow the prompts. The installer requires administrator privileges.
  • On macOS, double-click Install Certificates.command in the /Applications/Python 3.14/ folder when the installer finishes. This installs SSL certificates that Python needs for HTTPS connections.
  • Close and reopen your terminal, then verify the installation by running:
python3 --version

You should see Python 3.14.6 or higher.

  • Verify that tkinter is available by running this command:
python3 -c "import tkinter; print('tkinter works')"

What does this command do?

The -c flag lets you run a short Python snippet directly from the terminal. This snippet tries to import tkinter and prints a success message. If tkinter is missing, you'll see an error instead.

You should see tkinter works printed in your terminal.

Seeing an import error?

If you installed Python 3.14 from the official macOS installer at python.org, tkinter is included automatically. Reinstall Python from https://www.python.org/downloads/ and make sure to use the default installer options.

Help me fix a tkinter import error on my machine after installing Python 3.14

Create the project folder and test tkinter

Now that Python is ready, you'll create a project folder and write a quick test to confirm that tkinter can draw a window on screen.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir mini-browser
cd mini-browser
  • Confirm the folder exists by running:
ls

The folder is empty for now. That's expected.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the mini-browser folder, then click Open.
  • In the Explorer sidebar on the left, hover over the MINI-BROWSER heading and click the New File icon.
  • Type browser.py and press Enter.

Your new file opens in the editor. Now you'll write a quick test to make sure tkinter can create a graphical window.

  • Paste this test code into browser.py:
import tkinter

# Create a test window to confirm tkinter works
window = tkinter.Tk()
tkinter.mainloop()

What does this code do?

  • import tkinter loads Python's built-in graphics library.
  • tkinter.Tk() creates a new window on your screen.
  • tkinter.mainloop() keeps the window open and listens for user interactions like clicks and key presses. Without this line, the window would appear and immediately close.
  • Save browser.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Switch back to your terminal and run the file:
python3 browser.py

A small blank window should appear on your screen. This is a tkinter window. It confirms that Python and tkinter are working correctly.

Don't see a window?

Make sure you saved browser.py before running the command. Also check that your terminal is inside the mini-browser folder by running pwd.

If you see an error about tkinter not being found, reinstall Python 3.14 from python.org using the default installer options. The official installer bundles tkinter.

Help me troubleshoot why my tkinter window isn't appearing when I run python3 browser.py

Clear the test code and get ready to build

The test window did its job. Now you'll clear out the test code so you start the real browser with a clean slate.

  • Close the tkinter test window by clicking its close button (the red circle on macOS, the X on Windows). Your terminal prompt reappears once the window is closed.
  • Switch back to VS Code and select all the code in browser.py by pressing Cmd+A (macOS) or Ctrl+A (Windows).
  • Press Delete to remove all the test code.
  • Save the empty file by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Your browser.py file is now empty and ready for the real browser code. In the next step, you'll build a network layer that fetches real web pages using raw TCP sockets.

Set Up Python 3 and Verify Tkinter

Every browser you use does four things to show you a web page: fetch it from the internet, parse the HTML, lay out the text, and paint it to your screen.

To paint text to the screen, your browser needs a graphical window. In this step, you'll verify that Python's built-in Tkinter library can create one, then set up your browser file with the imports and constants it needs.

In this step, get ready to:
  • Confirm Tkinter creates a window on your machine.
  • Set up browser.py with the imports and constants for your browser.
Run a tkinter window test

Tkinter ships with Python, so there's nothing to install. Let's make sure it can actually create a window on your machine.

  • In browser.py, add this test code:
import tkinter

# Create a blank window to test that tkinter is working
window = tkinter.Tk()
tkinter.mainloop()

What does this code do?

  • tkinter.Tk() creates a new window on your screen.
  • tkinter.mainloop() keeps the window open and listening for events like clicks and key presses. Without it, the window would flash and immediately close.
  • Save browser.py.
  • Open the built-in terminal in VS Code by pressing Ctrl+` (backtick key, next to the 1 key).
  • Run the test file with this command:
python3 browser.py

You should see a small, blank window appear on your screen. It might say tk in the title bar.

No window appearing?

  • Make sure you saved browser.py before running the command. Unsaved changes won't be picked up.
  • Check that your terminal is in the mini-browser folder. If not, run cd ~/Desktop/mini-browser and try again.
  • If you see an error about tkinter not being found, confirm you are using the Python 3.14 you installed earlier by running python3 --version.

Help me debug why my tkinter test window isn't appearing.

Set up your browser file

The test worked. Now it's time to replace that test code with the real foundation for your browser.

  • Close the tkinter test window (click the window's close button or press Ctrl+C in the terminal).
  • Select all the code in browser.py by pressing Cmd+A (macOS) or Ctrl+A (Windows), then delete it.
  • Add these imports at the top of the now-empty browser.py:
# socket: connect to web servers over the network
import socket
# ssl: encrypt connections for HTTPS websites
import ssl
# tkinter: build the graphical browser window
import tkinter
# tkinter.font: measure and style text for the layout engine
import tkinter.font

What are these imports for?

  • socket lets your browser open raw TCP connections to web servers. This is the lowest level of network communication.
  • ssl wraps those connections with SSL/TLS encryption so your browser can load HTTPS pages.
  • tkinter provides the window and canvas your browser draws on.
  • tkinter.font lets you measure how wide each word is in pixels. Your layout engine needs this to know when to wrap text to the next line.

You won't see a change yet. These imports load the tools your browser will use in the next steps.

  • Below the imports, add a blank line and then these constants:
# Browser window size in pixels
WIDTH, HEIGHT = 800, 600
# Horizontal and vertical spacing for text positioning
HSTEP, VSTEP = 13, 18
# Pixels to move per scroll event
SCROLL_STEP = 100

What do these constants control?

  • WIDTH, HEIGHT = 800, 600 sets your browser window to 800 pixels wide and 600 pixels tall.
  • HSTEP, VSTEP = 13, 18 controls horizontal and vertical spacing between words. Your layout engine uses these to position each word on the page.
  • SCROLL_STEP = 100 determines how many pixels the page moves each time the user presses an arrow key.
  • Save browser.py.

Your file now has the foundation your browser needs. The constants won't produce any visible output on their own. They'll come to life when you build the layout engine and browser window in later steps.

✔️ Awesome, I've got everything!

Great. Double check that you've saved browser.py and you're ready for the next step.

ⓧ I'd like to double check the full code

Here's what browser.py should look like so far:

import socket
import ssl
import tkinter
import tkinter.font

WIDTH, HEIGHT = 800, 600
HSTEP, VSTEP = 13, 18
SCROLL_STEP = 100

Your browser file is set up and ready to go. Next up, you'll build the network layer that fetches real web pages over HTTP and HTTPS using raw sockets.

Build the Network Layer

Python 3 and Tkinter are ready to go. Now it's time to build the most fundamental piece of any web browser: the network layer.

Your browser needs to speak HTTP, the same protocol that web servers use. In this step, you'll build a URL class that opens raw TCP connections and fetches web pages.

In this step, get ready to:
  • Build a URL parser that extracts the scheme, host, port, and path from any URL.
  • Write a request method that fetches web pages over raw TCP sockets.
  • Test your network layer against a live website to verify HTTP and HTTPS both work.
Build the URL parser

A URL like http://example.org/index.html packs several pieces of information into one string: the scheme, the host, the port, and the path. Your browser needs to pull these apart before it can connect to the right server.

  • In browser.py, add the following URL class below the SCROLL_STEP constant:
class URL:
    def __init__(self, url):
        # Split the URL into scheme (http/https) and the rest
        self.scheme, url = url.split("://", 1)
        assert self.scheme in ["http", "https"]

        # Set the default port based on the scheme
        if self.scheme == "http":
            self.port = 80
        elif self.scheme == "https":
            self.port = 443

        # Ensure there's always a path, defaulting to "/"
        if "/" not in url:
            url = url + "/"
        # Split into host and path
        self.host, url = url.split("/", 1)
        self.path = "/" + url

        # Handle custom ports like example.com:8080
        if ":\" in self.host:
            self.host, port = self.host.split(":", 1)
            self.port = int(port)

What does this code do?

The __init__ method takes a full URL string and breaks it into the four pieces your browser needs to make a connection.

  • self.scheme stores whether the URL uses http or https. This determines whether the connection needs encryption.
  • self.port defaults to 80 for HTTP and 443 for HTTPS. If the URL includes a custom port like :8080, the parser overrides the default.
  • self.host and self.path tell your browser which server to connect to and which page to request.
  • Save browser.py.

You won't see a change yet. The next substep adds the request method that uses these parsed values to fetch a web page.

Seeing red underlines in VS Code?

  • Check that all lines inside __init__ are indented with spaces, not tabs. Python is sensitive to indentation style.
  • Make sure the class URL: line itself is not indented. It should start at the left edge of the file.
  • help me fix indentation errors in my URL class
Add the request method

Your URL class can parse a URL, but it can't fetch anything yet. The request method opens a raw TCP socket to the server, optionally wraps it with SSL for HTTPS, sends an HTTP request, and reads back the response.

Why HTTP/1.0 instead of HTTP/1.1?

HTTP/1.1 introduces features like chunked transfer encoding and persistent connections that add complexity. HTTP/1.0 closes the connection after each response, which makes the code much simpler.

For learning how browsers work under the hood, HTTP/1.0 gives you all the fundamentals without the extra plumbing.

  • Inside the URL class, add the request method below the __init__ method:
    def request(self):
        # Create a TCP socket
        s = socket.socket(
            family=socket.AF_INET,
            type=socket.SOCK_STREAM,
            proto=socket.IPPROTO_TCP,
        )
        s.connect((self.host, self.port))

        # Wrap with SSL for HTTPS connections
        if self.scheme == "https":
            ctx = ssl.create_default_context()
            s = ctx.wrap_socket(s, server_hostname=self.host)

        # Build and send the HTTP/1.0 GET request
        request = "GET {} HTTP/1.0\r\n".format(self.path)
        request += "Host: {}\r\n".format(self.host)
        request += "\r\n"
        s.send(request.encode("utf8"))

        # Read the response as a file-like object
        response = s.makefile("r", encoding="utf8", newline="\r\n")

        # Parse the status line (e.g., "HTTP/1.0 200 OK")
        statusline = response.readline()
        version, status, explanation = statusline.split(" ", 2)

        # Parse response headers into a dictionary
        response_headers = {}
        while True:
            line = response.readline()
            if line == "\r\n":
                break
            header, value = line.split(":", 1)
            response_headers[header.casefold()] = value.strip()

        # Ensure no special encodings we can't handle
        assert "transfer-encoding" not in response_headers
        assert "content-encoding" not in response_headers

        # Read the body and close the connection
        content = response.read()
        s.close()
        return content

What does this code do?

The request method follows the same steps a real browser takes to load a page.

  • socket.socket() creates a raw TCP connection. s.connect() opens the connection to the server on the correct port.
  • For HTTPS URLs, ssl.create_default_context() and wrap_socket() add encryption to the connection. This is the same TLS handshake your regular browser performs.
  • The request string follows the HTTP protocol format: a GET line, a Host header, and a blank line to signal the end of the request. The \r\n line endings are required by the HTTP spec.
  • s.makefile() turns the socket into a file-like object so you can read lines one at a time. The method then parses the status line, reads headers into a dictionary, and returns the page body.
  • Save browser.py.

You won't see output yet. The next substep adds test code so you can verify the request works against a live website.

Request method not lining up with the class?

  • The def request(self): line must be indented one level inside the URL class, at the same level as def __init__(self, url):.
  • Make sure there is no blank class URL: line separating __init__ and request. Both methods belong inside the same class.
  • help me fix my request method indentation inside the URL class

✔️ Awesome, I've got everything!

Great. Double check you've saved browser.py before moving on.

ⓧ I'd like to double check the full code

Here is the full browser.py file up to this point. Compare your code against it:

import socket
import ssl
import tkinter
import tkinter.font

WIDTH, HEIGHT = 800, 600
HSTEP, VSTEP = 13, 18
SCROLL_STEP = 100


class URL:
    def __init__(self, url):
        self.scheme, url = url.split("://", 1)
        assert self.scheme in ["http", "https"]

        if self.scheme == "http":
            self.port = 80
        elif self.scheme == "https":
            self.port = 443

        if "/" not in url:
            url = url + "/"
        self.host, url = url.split("/", 1)
        self.path = "/" + url

        if ":" in self.host:
            self.host, port = self.host.split(":", 1)
            self.port = int(port)

    def request(self):
        s = socket.socket(
            family=socket.AF_INET,
            type=socket.SOCK_STREAM,
            proto=socket.IPPROTO_TCP,
        )
        s.connect((self.host, self.port))

        if self.scheme == "https":
            ctx = ssl.create_default_context()
            s = ctx.wrap_socket(s, server_hostname=self.host)

        request = "GET {} HTTP/1.0\r\n".format(self.path)
        request += "Host: {}\r\n".format(self.host)
        request += "\r\n"
        s.send(request.encode("utf8"))

        response = s.makefile("r", encoding="utf8", newline="\r\n")

        statusline = response.readline()
        version, status, explanation = statusline.split(" ", 2)

        response_headers = {}
        while True:
            line = response.readline()
            if line == "\r\n":
                break
            header, value = line.split(":", 1)
            response_headers[header.casefold()] = value.strip()

        assert "transfer-encoding" not in response_headers
        assert "content-encoding" not in response_headers

        content = response.read()
        s.close()
        return content
Test your browser's network layer

Your URL class can parse URLs and build HTTP requests. Time to point it at a real website and see if it works.

  • At the very bottom of browser.py (outside the URL class), add this temporary test code:
# Temporary test - delete after verifying
url = URL("http://example.org/")
body = url.request()
print(body[:200])

What does this test do?

This creates a URL object pointing at http://example.org/, calls request() to fetch the page, and prints the first 200 characters of the raw HTML response.

  • Save browser.py.
  • Run the script by entering this in your terminal:
python3 browser.py

You should see raw HTML printed in your terminal, starting with <!doctype html> followed by <html>, <head>, and <title>Example Domain</title>. This is the actual HTML that your browser fetched over a raw TCP socket.

Now test that HTTPS works too. Your browser should be able to handle encrypted connections.

  • In your test code at the bottom of browser.py, find this line:
url = URL("http://example.org/")
  • Change http://example.org/ to https://example.org/, so the line reads:
url = URL("https://example.org/")
  • Save browser.py and run the script again:
python3 browser.py

You should see the same HTML output as before. The ssl module handled the TLS encryption behind the scenes.

Getting an SSL certificate error?

On macOS, Python's ssl module may not trust any certificates until you run the **Install Certificates** command that comes with the Python installer.

  • Open **Finder** and navigate to your **Applications** folder.
  • Look for a folder called **Python 3.14**.
  • Double-click **Install Certificates.command** inside that folder. A terminal window opens and installs the certificates.
  • After it finishes, run python3 browser.py again. The HTTPS request should now work.

help me fix SSL certificate errors when running my Python browser script

🙋‍♀️ Seeing a ConnectionRefusedError or timeout?

  • Make sure you're connected to the internet. The script needs to reach example.org over the network.
  • Check that the URL string in your test code is spelled exactly as http://example.org/ or https://example.org/. A typo in the hostname causes a connection failure.
  • help me debug a connection error in my Python socket code
  • Now that both HTTP and HTTPS work, delete the four temporary test lines at the bottom of browser.py (the comment, the url = line, the body = line, and the print line).
  • Save browser.py.

Your browser can now reach out to any web server and pull back raw HTML. Next up, you'll parse that HTML and build a layout engine that figures out where every word belongs on screen.

Parse HTML and Build the Layout Engine

Your browser can now fetch raw HTML from any web server on the internet. That's a big deal.

But the response is a mess of angle brackets and tag names. In this step, you'll write two functions that transform raw HTML into something drawable. First, a parser strips away the tags to get clean text. Then a layout engine figures out where every word belongs on screen.

In this step, get ready to:
  • Write a lex function that strips HTML tags from a raw response body.
  • Build a layout function that computes pixel positions for every word.
  • Verify both functions produce correct output by printing results in the terminal.
Write a function to strip HTML tags

The response from request() is full of HTML tags like <html>, <body>, and <p>. We need a function that walks through each character and keeps only the visible text.

  • In browser.py, add the lex function below your URL class by copying this code:
def lex(body):
    # Walk through each character in the raw HTML
    text = ""
    in_tag = False
    for c in body:
        if c == "<":
            # Start of an HTML tag, begin skipping
            in_tag = True
        elif c == ">":
            # End of the HTML tag, stop skipping
            in_tag = False
        elif not in_tag:
            # Regular text character, keep it
            text += c
    return text

What does this code do?

The lex function uses a boolean called in_tag that works like a light switch.

  • When the function sees <, the switch flips to True and every character gets skipped.
  • When it hits >, the switch flips back to False and characters are added to the output string again.
  • Only characters outside of tags make it into the final text string.
  • Save browser.py.
  • Test your lex function by adding this temporary test code at the very bottom of browser.py:
# Temporary test code - you'll delete this later
url = URL("http://example.org/")
body = url.request()
print(lex(body))
  • Save browser.py, then run the test by running:
python3 browser.py

You should see clean, readable text in your terminal. The output starts with "Example Domain" followed by a paragraph about using this domain in illustrative examples. No HTML tags like <html> or <body> should be visible.

Seeing HTML tags in the output?

  • Check that the lex function is placed outside the URL class (not indented under it). It should be at the top level of the file.
  • Make sure the test code at the bottom calls lex(body), not just print(body). Without lex, you'll see raw HTML.
  • Verify that in_tag is set to True on < and False on >. Swapping those would strip the text and keep the tags.

Still stuck? Help me debug why my lex function is still showing HTML tags in the output.

Build the layout function

Clean text is great, but a browser needs to know where to draw each word on screen. The layout function works like a typewriter. It places words left to right, measuring each word's pixel width. When a word won't fit on the current line, it wraps to the next one.

What is a display list?

A display list is a list of drawing instructions. Each entry says: "draw this word at this (x, y) position using this font." The layout function builds this list. Later, a separate drawing step reads it and paints the words onto the screen.

Separating layout from drawing is how real browsers work. Chrome, Firefox, and Safari all compute positions first, then paint pixels.

The constants WIDTH, HEIGHT, HSTEP, and VSTEP are already defined at the top of your file from the previous step. The layout function uses them for margins and spacing.

  • In browser.py, add the layout function below your lex function. Place it above the temporary test code at the bottom:
def layout(text):
    # Create a default font for measuring word widths
    font = tkinter.font.Font()
    display_list = []
    # Start the cursor at a small margin from the top-left
    cursor_x, cursor_y = HSTEP, VSTEP
    for word in text.split():
        # Measure this word's pixel width
        w = font.measure(word)
        # Wrap to the next line if the word won't fit
        if cursor_x + w > WIDTH - HSTEP:
            cursor_y += font.metrics("linespace") * 1.25
            cursor_x = HSTEP
        # Record the word's position in the display list
        display_list.append((cursor_x, cursor_y, word, font))
        # Advance the cursor past the word plus a space
        cursor_x += w + font.measure(" ")
    return display_list

What does this code do?

  • tkinter.font.Font() creates a default font object. The function uses font.measure(word) to get the exact pixel width of each word.
  • cursor_x and cursor_y track the current drawing position, starting at (HSTEP, VSTEP) to add a small margin.
  • When cursor_x + w exceeds WIDTH - HSTEP, the word wraps to the next line. The y position increases by font.metrics("linespace") * 1.25 to add comfortable line spacing.
  • Each word's position, text, and font are stored as a tuple in the display_list.
  • Save browser.py.

You won't see a change yet. The next substep tests this function.

Verify the layout engine

The layout function needs a Tkinter window to exist before it can measure fonts. The test code below creates one so the font measurements work.

  • In browser.py, find your temporary test code at the bottom of the file.
  • Find this code:
# Temporary test code - you'll delete this later
url = URL("http://example.org/")
body = url.request()
print(lex(body))
  • Replace it with this updated test code that runs both lex and layout:
# Temporary test code - delete after verifying
window = tkinter.Tk()
url = URL("http://example.org/")
body = url.request()
text = lex(body)
dl = layout(text)
for item in dl[:10]:
    print(item)

Why do we need tkinter.Tk() here?

tkinter.font.Font() can only be called after a Tkinter window exists. The tkinter.Tk() line creates that window so the font measurements inside layout work correctly. You'll see a blank window briefly appear and close.

  • Save browser.py, then run the test by running:
python3 browser.py

You should see 10 tuples printed to your terminal. Each tuple contains four values: the x position, the y position, the word, and the font object.

What should I see?

The x values should increase across each line as words are placed left to right. When a word wraps to a new line, the x value resets back to 13 (the HSTEP margin) and the y value jumps to a larger number.

  • Now that you've verified both functions work, delete the temporary test code at the bottom of the file.
  • Save browser.py.

Seeing an error about fonts or Tk?

  • If you see an error mentioning Font or TclError, make sure window = tkinter.Tk() appears before the call to layout(text) in your test code.
  • If your x values don't reset on new lines, check that the if cursor_x + w > WIDTH - HSTEP: line uses WIDTH (the constant), not a hardcoded number.
  • Make sure the layout function is placed at the top level of the file (not indented under the URL class or inside lex).

Still stuck? Help me debug my layout function. I'm getting an error when I try to run python3 browser.py to test the display list output.

✔️ Awesome, I've got everything!

Great. Double check you've saved browser.py and deleted the temporary test code at the bottom of the file.

ⓧ I'd like to double check the full code

Here's the complete browser.py file at this point. Compare it with yours to spot any differences.

import socket
import ssl
import tkinter
import tkinter.font

WIDTH, HEIGHT = 800, 600
HSTEP, VSTEP = 13, 18
SCROLL_STEP = 100


class URL:
    def __init__(self, url):
        self.scheme, url = url.split("://", 1)
        assert self.scheme in ["http", "https"]

        if self.scheme == "http":
            self.port = 80
        elif self.scheme == "https":
            self.port = 443

        if "/" not in url:
            url = url + "/"
        self.host, url = url.split("/", 1)
        self.path = "/" + url

        if ":" in self.host:
            self.host, port = self.host.split(":", 1)
            self.port = int(port)

    def request(self):
        s = socket.socket(
            family=socket.AF_INET,
            type=socket.SOCK_STREAM,
            proto=socket.IPPROTO_TCP,
        )
        s.connect((self.host, self.port))

        if self.scheme == "https":
            ctx = ssl.create_default_context()
            s = ctx.wrap_socket(s, server_hostname=self.host)

        request = "GET {} HTTP/1.0\r\n".format(self.path)
        request += "Host: {}\r\n".format(self.host)
        request += "\r\n"
        s.send(request.encode("utf8"))

        response = s.makefile("r", encoding="utf8", newline="\r\n")

        statusline = response.readline()
        version, status, explanation = statusline.split(" ", 2)

        response_headers = {}
        while True:
            line = response.readline()
            if line == "\r\n":
                break
            header, value = line.split(":", 1)
            response_headers[header.casefold()] = value.strip()

        assert "transfer-encoding" not in response_headers
        assert "content-encoding" not in response_headers

        content = response.read()
        s.close()
        return content


def lex(body):
    text = ""
    in_tag = False
    for c in body:
        if c == "<":
            in_tag = True
        elif c == ">":
            in_tag = False
        elif not in_tag:
            text += c
    return text


def layout(text):
    font = tkinter.font.Font()
    display_list = []
    cursor_x, cursor_y = HSTEP, VSTEP
    for word in text.split():
        w = font.measure(word)
        if cursor_x + w > WIDTH - HSTEP:
            cursor_y += font.metrics("linespace") * 1.25
            cursor_x = HSTEP
        display_list.append((cursor_x, cursor_y, word, font))
        cursor_x += w + font.measure(" ")
    return display_list

Your browser can now fetch a web page, strip out the HTML tags, and compute where every word belongs on screen. Next up, you'll build the graphical window that actually draws those words and lets you scroll through the page.

Render to Screen with Scrolling

Your browser can now fetch a web page, strip its HTML tags, and compute pixel positions for every word. The one thing missing is a window to actually show those words in.

In this step, you will build a Browser class that opens a Tkinter window, paints the display list to a canvas, and lets you scroll through the page with your arrow keys.

In this step, get ready to:
  • Build a Browser class that creates a window and canvas.
  • Add load and draw methods that render the display list to the screen.
  • Wire up keyboard scrolling and test your browser on a live web page.
Create the Browser class

Every graphical application needs a window and a surface to draw on. The Browser class will own both of those, plus a scroll offset that tracks how far down the page the user has scrolled.

  • In browser.py, scroll to the bottom of the file, below the layout function.
  • Start the Browser class by adding its __init__ method:
class Browser:
    def __init__(self):
        # Create a window and drawing canvas
        self.window = tkinter.Tk()
        self.canvas = tkinter.Canvas(
            self.window,
            width=WIDTH,
            height=HEIGHT,
        )
        self.canvas.pack()
        # Track how far the user has scrolled
        self.scroll = 0
        # Bind arrow keys to scroll handler methods
        self.window.bind("<Down>", self.scrolldown)
        self.window.bind("<Up>", self.scrollup)

What does this code do?

tkinter.Tk() creates the main application window. The Canvas is the drawing surface where text will appear, sized to the WIDTH and HEIGHT constants you defined earlier (800x600 pixels).

self.scroll starts at 0, meaning the page is at the very top. The window.bind calls connect the Down and Up arrow keys to methods you will add shortly.

  • You won't see a change yet. The next methods will bring this class to life.
Add the load and draw methods

The load method ties together every piece you have built so far: fetch the page, strip HTML, compute the layout, then paint.

The draw method walks through the display list and places each word on the canvas. It also skips words that are off-screen so the browser stays fast on long pages.

  • Add the load and draw methods inside the Browser class, right below __init__:
    def load(self, url):
        # Fetch the page, strip HTML tags, compute word positions
        body = url.request()
        text = lex(body)
        self.display_list = layout(text)
        self.draw()

    def draw(self):
        # Clear the canvas before redrawing
        self.canvas.delete("all")
        for x, y, word, font in self.display_list:
            # Skip words that are below the visible window
            if y > self.scroll + HEIGHT:
                continue
            # Skip words that are above the visible window
            if y + VSTEP < self.scroll:
                continue
            # Draw the word, offset by the scroll position
            self.canvas.create_text(
                x, y - self.scroll, text=word, font=font, anchor="nw"
            )

What does this code do?

  • load is the full browser pipeline: it calls url.request() to fetch the HTML, lex() to strip tags, and layout() to compute where each word goes. It stores the result in self.display_list and calls draw().
  • draw clears the canvas with canvas.delete("all"), then loops through the display list. For each word, it subtracts self.scroll from the y position so the page shifts upward as you scroll down.
  • The two continue checks skip words that are off-screen. This keeps drawing fast because the canvas only handles visible words.
  • anchor="nw" positions each word by its top-left corner (northwest). Without this, Tkinter positions text by its center, which causes overlapping.
  • You won't see a change yet. The next code adds scroll support and the entry point so you can run the browser.
Add scroll support and run your browser

The __init__ method already binds the arrow keys to scrolldown and scrollup. Now you need to write those methods so pressing the keys actually moves the page.

  • Add the scrolldown and scrollup methods inside the Browser class, below the draw method:
    def scrolldown(self, e):
        self.scroll += SCROLL_STEP
        self.draw()

    def scrollup(self, e):
        # Prevent scrolling above the top of the page
        if self.scroll > 0:
            self.scroll -= SCROLL_STEP
        self.draw()

The last piece is an entry point that reads a URL from the command line and launches the browser.

  • At the very bottom of browser.py, outside the Browser class (no indentation), add the entry point:
if __name__ == "__main__":
    import sys
    # Load whatever URL the user passes as a command-line argument
    Browser().load(URL(sys.argv[1]))
    tkinter.mainloop()

What does this code do?

  • scrolldown increases self.scroll by SCROLL_STEP (100 pixels) and redraws. scrollup does the reverse, but only if the scroll offset is above 0 so you cannot scroll past the top of the page.
  • Both methods accept an event parameter e because Tkinter passes a keyboard event object to every bound handler. The code does not use it, but the parameter must be there.
  • if __name__ == "__main__" ensures the browser only launches when you run the file directly. sys.argv[1] grabs the first command-line argument (the URL). tkinter.mainloop() keeps the window open and listening for keyboard events.
  • Save browser.py.
  • Switch back to your Terminal and run your browser by running this command:
python3 browser.py https://example.org/
  • You should see a window appear with the text content of example.org rendered inside it.
  • Press the Down arrow key to scroll down the page.
  • Press the Up arrow key to scroll back up.
  • Close the browser window, then try loading a different URL to see more text:
python3 browser.py https://browser.engineering/

Not seeing a window?

  • Check that the entry point block (if __name__ == "__main__") is at the very left edge of the file with no indentation. If it is indented inside the Browser class, Python treats it as a method definition instead of a script entry point.
  • If you see an IndexError about sys.argv[1], make sure you included a URL after the filename. The command needs a URL argument like https://example.org/.
  • If you see a certificate error on HTTPS URLs, you may need to run the Install Certificates script that comes with your Python installation. On macOS, it is located at /Applications/Python 3.14/Install Certificates.command. Double-click it to run it.

Why isn't my mini browser window showing up when I run the script?

✔️ Awesome, I've got everything!

Great. Double check you have saved browser.py.

ⓧ I'd like to double check the full code

Here is the complete browser.py file. Compare your code against it carefully.

import socket
import ssl
import tkinter
import tkinter.font

WIDTH, HEIGHT = 800, 600
HSTEP, VSTEP = 13, 18
SCROLL_STEP = 100


class URL:
    def __init__(self, url):
        self.scheme, url = url.split("://", 1)
        assert self.scheme in ["http", "https"]

        if self.scheme == "http":
            self.port = 80
        elif self.scheme == "https":
            self.port = 443

        if "/" not in url:
            url = url + "/"
        self.host, url = url.split("/", 1)
        self.path = "/" + url

        if ":" in self.host:
            self.host, port = self.host.split(":", 1)
            self.port = int(port)

    def request(self):
        s = socket.socket(
            family=socket.AF_INET,
            type=socket.SOCK_STREAM,
            proto=socket.IPPROTO_TCP,
        )
        s.connect((self.host, self.port))

        if self.scheme == "https":
            ctx = ssl.create_default_context()
            s = ctx.wrap_socket(s, server_hostname=self.host)

        request = "GET {} HTTP/1.0\r\n".format(self.path)
        request += "Host: {}\r\n".format(self.host)
        request += "\r\n"
        s.send(request.encode("utf8"))

        response = s.makefile("r", encoding="utf8", newline="\r\n")

        statusline = response.readline()
        version, status, explanation = statusline.split(" ", 2)

        response_headers = {}
        while True:
            line = response.readline()
            if line == "\r\n":
                break
            header, value = line.split(":", 1)
            response_headers[header.casefold()] = value.strip()

        assert "transfer-encoding" not in response_headers
        assert "content-encoding" not in response_headers

        content = response.read()
        s.close()
        return content


def lex(body):
    text = ""
    in_tag = False
    for c in body:
        if c == "<":
            in_tag = True
        elif c == ">":
            in_tag = False
        elif not in_tag:
            text += c
    return text


def layout(text):
    font = tkinter.font.Font()
    display_list = []
    cursor_x, cursor_y = HSTEP, VSTEP
    for word in text.split():
        w = font.measure(word)
        if cursor_x + w > WIDTH - HSTEP:
            cursor_y += font.metrics("linespace") * 1.25
            cursor_x = HSTEP
        display_list.append((cursor_x, cursor_y, word, font))
        cursor_x += w + font.measure(" ")
    return display_list


class Browser:
    def __init__(self):
        self.window = tkinter.Tk()
        self.canvas = tkinter.Canvas(
            self.window,
            width=WIDTH,
            height=HEIGHT,
        )
        self.canvas.pack()
        self.scroll = 0
        self.window.bind("<Down>", self.scrolldown)
        self.window.bind("<Up>", self.scrollup)

    def load(self, url):
        body = url.request()
        text = lex(body)
        self.display_list = layout(text)
        self.draw()

    def draw(self):
        self.canvas.delete("all")
        for x, y, word, font in self.display_list:
            if y > self.scroll + HEIGHT:
                continue
            if y + VSTEP < self.scroll:
                continue
            self.canvas.create_text(
                x, y - self.scroll, text=word, font=font, anchor="nw"
            )

    def scrolldown(self, e):
        self.scroll += SCROLL_STEP
        self.draw()

    def scrollup(self, e):
        if self.scroll > 0:
            self.scroll -= SCROLL_STEP
        self.draw()


if __name__ == "__main__":
    import sys
    Browser().load(URL(sys.argv[1]))
    tkinter.mainloop()

Secret mission

Add Bold and Italic Text Rendering

Your browser currently renders every word in the same font. Real web pages use bold and italic text to add emphasis and structure. Can you upgrade the browser to handle <b> and <i> tags?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your project files or delete them.

Resources you used:

  • The mini-browser project folder on your Desktop.

Keep everything running

No action needed. Choose this if you plan to keep experimenting with your browser or extending it further.

  • Your mini-browser folder stays on your Desktop, ready to run anytime.
  • Python 3.14 remains installed on your machine. It is useful for future Python projects.

Pause - I'll come back to this later

Close any running processes to free up memory. Your files stay on your Desktop for when you come back.

  • Close the browser window if it is still open.
  • Close VS Code to free up memory.
  • Your mini-browser folder stays on your Desktop whenever you are ready to return.

Delete - I don't want to use this again

Remove all project files from your machine.

  • Close VS Code if it is still open.
  • Delete the mini-browser folder from your Desktop.

macOS

  • Open Terminal and run this command to delete the project folder:
rm -rf ~/Desktop/mini-browser
  • Confirm the folder is gone by running:
ls ~/Desktop/mini-browser

You should see a "No such file or directory" message.

Windows

  • Open PowerShell and run this command to delete the project folder:
Remove-Item -Recurse -Force ~\Desktop\mini-browser
  • Confirm the folder is gone by running:
Test-Path ~\Desktop\mini-browser

You should see False in the output.

What about Python 3.14?

Python 3.14 is a general-purpose programming language that is useful well beyond this project. We recommend keeping it installed for future projects.

That's a Wrap!

That's a Wrap!

Nice work! You just built a web browser from scratch in a single Python file.

Your browser implements the same core pipeline that Chrome, Firefox, and Safari use internally: network fetch, HTML parse, layout, and paint.

You've learned how to:

  • Build a URL class that makes raw HTTP/1.0 requests over TCP sockets and handles HTTPS with SSL/TLS encryption.
  • Write an HTML parser and a layout engine that computes word positions with proper line wrapping.
  • Render web page text in a scrollable Tkinter window with keyboard navigation using a display list.
  • Secret Mission: Upgrade the lexer to a full HTML tokenizer and build a Layout class that renders bold and italic text with proper baseline alignment.

Ready to quiz yourself?