Go’s standard library net/http package does a lot. I wanted to understand what it’s actually doing, so I built a minimal HTTP/1.1 server using only net.Listen — no net/http, no frameworks.

This isn’t something you’d ship. It’s a learning exercise, and a useful one.

The plan

Handle exactly one thing: a GET / that returns 200 OK with a plain text body. Everything else gets a 404.

Listening for connections

package main

import (
    "fmt"
    "net"
    "strings"
)

func main() {
    ln, err := net.Listen("tcp", ":8080")
    if err != nil {
        panic(err)
    }
    fmt.Println("listening on :8080")

    for {
        conn, err := ln.Accept()
        if err != nil {
            fmt.Println("accept error:", err)
            continue
        }
        go handle(conn)
    }
}

Each accepted connection gets its own goroutine. This is exactly what net/http does internally, wrapped in a much more capable connection pool.

Parsing the request line

HTTP/1.1 requests start with a line like GET / HTTP/1.1. We only need the method and path:

func handle(conn net.Conn) {
    defer conn.Close()

    buf := make([]byte, 4096)
    n, err := conn.Read(buf)
    if err != nil {
        return
    }

    request := string(buf[:n])
    lines := strings.Split(request, "\r\n")
    if len(lines) == 0 {
        return
    }

    parts := strings.Fields(lines[0])
    if len(parts) < 2 {
        return
    }

    method, path := parts[0], parts[1]
    route(conn, method, path)
}

Routing and responding

func route(conn net.Conn, method, path string) {
    if method == "GET" && path == "/" {
        respond(conn, 200, "text/plain", "hello from scratch")
        return
    }
    respond(conn, 404, "text/plain", "not found")
}

func respond(conn net.Conn, status int, contentType, body string) {
    statusText := map[int]string{
        200: "OK",
        404: "Not Found",
    }
    fmt.Fprintf(conn,
        "HTTP/1.1 %d %s\r\nContent-Type: %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s",
        status, statusText[status], contentType, len(body), body,
    )
}

Running it

go run main.go &
curl -v http://localhost:8080/

You’ll see the raw response headers in curl’s output — satisfying when it’s code you wrote.

What this skips

Real HTTP servers handle keep-alive connections, chunked transfer encoding, request body parsing, header size limits, timeouts on slow clients, and a lot more. net/http handles all of that. This exercise is useful precisely because building the toy version makes you appreciate why the standard library exists.

Next step if you want to go deeper: implement keep-alive by looping inside handle() until the connection is closed or times out.