Building a Tiny HTTP Server from Scratch with net + bufio

Go’s net/http package is fantastic—but sometimes you want to see what’s really happening under the hood. In this article, we’ll build a tiny HTTP/1.1 server …

Go’s net/http package is fantastic—but sometimes you want to see what’s really happening under the hood.

In this article, we’ll build a tiny HTTP/1.1 server from scratch, using only:

  • net — for TCP sockets
  • bufio — for buffered I/O

No net/http, no handler interface, no magic.

You’ll learn:

  • How HTTP/1.1 requests look on the wire
  • How to parse the request line and headers
  • How to build a minimal response (status line, headers, body)
  • How to handle multiple connections with goroutines
  • A tiny router for different paths

This is not production code—it’s a learning tool. But it’s a great way to deeply understand HTTP.

1. What HTTP Looks Like on the Wire

A simple HTTP/1.1 request:

GET /hello HTTP/1.1
Host: localhost:8080
User-Agent: curl/8.0
Accept: */*

Key parts:

  1. Request line: METHOD PATH VERSION
    Example: GET /hello HTTP/1.1
  2. Headers: Name: Value
  3. Blank line: marks the end of headers
  4. Optional body (for POST, etc.)

A minimal HTTP/1.1 response:

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 13

Hello, world!

We’ll build exactly this kind of flow manually.

2. Project Skeleton

Create a folder:

mkdir tiny-http
cd tiny-http
go mod init example.com/tiny-http

We’ll write everything in main.go for simplicity.

3. Minimal TCP Accept Loop

First, let’s just accept TCP connections and print incoming bytes.

package main

import (
    "bufio"
    "fmt"
    "net"
)

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 handleConn(conn)
    }
}

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

    reader := bufio.NewReader(conn)
    line, err := reader.ReadString('\n')
    if err != nil {
        fmt.Println("Read error:", err)
        return
    }

    fmt.Println("First line from client:", line)
}

Run it:

go run .

Then in another terminal:

curl -v http://localhost:8080/

You’ll see the first request line printed.

4. Parsing the Request Line and Headers

We’ll build a tiny Request struct and parse enough of HTTP to get:

  • Method
  • Path
  • Headers (as a map)
  • Optional body (we’ll keep this simple)

4.1 Define a Simple Request Type

type Request struct {
    Method  string
    Path    string
    Version string
    Header  map[string]string
    Body    []byte
}

4.2 Parse Request Using bufio.Reader

We’ll write a readRequest function.

import (
    "bufio"
    "errors"
    "io"
    "strings"
)

// readRequest reads a single HTTP/1.1 request from the connection.
func readRequest(reader *bufio.Reader) (*Request, error) {
    // Read request line: e.g. "GET /hello HTTP/1.1\r\n"
    line, err := reader.ReadString('\n')
    if err != nil {
        return nil, err
    }
    line = strings.TrimSpace(line)
    if line == "" {
        return nil, errors.New("empty request line")
    }

    parts := strings.Split(line, " ")
    if len(parts) != 3 {
        return nil, fmt.Errorf("malformed request line: %q", line)
    }

    req := &Request{
        Method: parts[0],
        Path:   parts[1],
        Version: parts[2],
        Header: make(map[string]string),
    }

    // Read headers until blank line
    for {
        hline, err := reader.ReadString('\n')
        if err != nil {
            return nil, err
        }
        hline = strings.TrimSpace(hline)
        if hline == "" {
            break // end of headers
        }

        idx := strings.Index(hline, ":")
        if idx == -1 {
            continue // skip malformed header
        }
        name := strings.TrimSpace(hline[:idx])
        value := strings.TrimSpace(hline[idx+1:])
        // Simple lowercase normalization
        req.Header[strings.ToLower(name)] = value
    }

    // Only handle small bodies via Content-Length (optional)
    if cl, ok := req.Header["content-length"]; ok {
        // naive conversion without robust error handling
        var length int
        fmt.Sscanf(cl, "%d", &length)
        if length > 0 {
            body := make([]byte, length)
            if _, err := io.ReadFull(reader, body); err != nil {
                return nil, err
            }
            req.Body = body
        }
    }

    return req, nil
}

This is intentionally minimal—but already reflects how real HTTP parsing works.

5. A Tiny Response Builder

We’ll build a helper to write HTTP responses manually.

type Response struct {
    StatusCode int
    StatusText string
    Header     map[string]string
    Body       []byte
}

func writeResponse(w *bufio.Writer, resp *Response) error {
    // Default to 200 OK if not set
    if resp.StatusCode == 0 {
        resp.StatusCode = 200
        resp.StatusText = "OK"
    }
    if resp.StatusText == "" {
        resp.StatusText = statusText(resp.StatusCode)
    }

    // Write status line
    _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", resp.StatusCode, resp.StatusText)
    if err != nil {
        return err
    }

    // Set Content-Length if not provided
    if resp.Header == nil {
        resp.Header = make(map[string]string)
    }
    if _, ok := resp.Header["Content-Length"]; !ok {
        resp.Header["Content-Length"] = fmt.Sprintf("%d", len(resp.Body))
    }
    if _, ok := resp.Header["Content-Type"]; !ok {
        resp.Header["Content-Type"] = "text/plain; charset=utf-8"
    }

    // Write headers
    for k, v := range resp.Header {
        _, err := fmt.Fprintf(w, "%s: %s\r\n", k, v)
        if err != nil {
            return err
        }
    }

    // End of headers
    if _, err := w.WriteString("\r\n"); err != nil {
        return err
    }

    // Write body
    if len(resp.Body) > 0 {
        if _, err := w.Write(resp.Body); err != nil {
            return err
        }
    }

    return w.Flush()
}

func statusText(code int) string {
    switch code {
    case 200:
        return "OK"
    case 404:
        return "Not Found"
    case 500:
        return "Internal Server Error"
    default:
        return "Status"
    }
}

Note: We’re not covering chunked encoding, compression, etc.—this is a tiny HTTP server.

6. Adding a Mini Router

Let’s route different paths by hand.

We’ll implement a simple handleRequest function:

func handleRequest(req *Request) *Response {
    switch {
    case req.Method == "GET" && req.Path == "/":
        return &Response{
            StatusCode: 200,
            Body:       []byte("Hello from tiny HTTP server\n"),
        }
    case req.Method == "GET" && req.Path == "/hello":
        return &Response{
            StatusCode: 200,
            Body:       []byte("Hello, world!\n"),
        }
    case req.Method == "POST" && req.Path == "/echo":
        return &Response{
            StatusCode: 200,
            Header: map[string]string{
                "Content-Type": "text/plain; charset=utf-8",
            },
            Body: req.Body,
        }
    default:
        return &Response{
            StatusCode: 404,
            Body:       []byte("404 page not found\n"),
        }
    }
}

7. Wiring It Together: Multi-Request (Keep-Alive) Handler

Let’s now rewrite handleConn to:

  • Use bufio.Reader + bufio.Writer
  • Parse the request
  • Route it
  • Write the response
  • Support multiple requests per connection (basic keep-alive)
func handleConn(conn net.Conn) {
    defer conn.Close()

    reader := bufio.NewReader(conn)
    writer := bufio.NewWriter(conn)

    for {
        // Try to read a request
        req, err := readRequest(reader)
        if err != nil {
            if err == io.EOF {
                fmt.Println("Client closed connection")
            } else if ne, ok := err.(net.Error); ok && ne.Timeout() {
                fmt.Println("Timeout:", err)
            } else {
                fmt.Println("Read error:", err)
            }
            return
        }

        fmt.Printf("Received %s %s\n", req.Method, req.Path)

        // Generate a response
        resp := handleRequest(req)

        // Very naive keep-alive decision: if client says "Connection: close", we close
        connectionHeader := strings.ToLower(req.Header["connection"])
        if connectionHeader == "close" {
            if resp.Header == nil {
                resp.Header = make(map[string]string)
            }
            resp.Header["Connection"] = "close"
            if err := writeResponse(writer, resp); err != nil {
                fmt.Println("Write error:", err)
            }
            return
        }

        // Otherwise, we keep it open (HTTP/1.1 default keep-alive)
        if err := writeResponse(writer, resp); err != nil {
            fmt.Println("Write error:", err)
            return
        }
    }
}

This server now supports:

  • Multiple requests over a single TCP connection (keep-alive)
  • Simple routing
  • GET and POST
  • Basic request/response parsing

8. Full Example: main.go

Here’s the complete tiny HTTP server in one file for convenience:

package main

import (
    "bufio"
    "errors"
    "fmt"
    "io"
    "net"
    "strings"
)

type Request struct {
    Method  string
    Path    string
    Version string
    Header  map[string]string
    Body    []byte
}

type Response struct {
    StatusCode int
    StatusText string
    Header     map[string]string
    Body       []byte
}

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

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

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

    reader := bufio.NewReader(conn)
    writer := bufio.NewWriter(conn)

    for {
        req, err := readRequest(reader)
        if err != nil {
            if err == io.EOF {
                return
            }
            if ne, ok := err.(net.Error); ok && ne.Timeout() {
                fmt.Println("Timeout:", err)
            } else {
                fmt.Println("Read error:", err)
            }
            return
        }

        fmt.Printf("Received %s %s\n", req.Method, req.Path)
        resp := handleRequest(req)

        connectionHeader := strings.ToLower(req.Header["connection"])
        if connectionHeader == "close" {
            if resp.Header == nil {
                resp.Header = make(map[string]string)
            }
            resp.Header["Connection"] = "close"
            if err := writeResponse(writer, resp); err != nil {
                fmt.Println("Write error:", err)
            }
            return
        }

        if err := writeResponse(writer, resp); err != nil {
            fmt.Println("Write error:", err)
            return
        }
    }
}

func readRequest(reader *bufio.Reader) (*Request, error) {
    line, err := reader.ReadString('\n')
    if err != nil {
        return nil, err
    }
    line = strings.TrimSpace(line)
    if line == "" {
        return nil, errors.New("empty request line")
    }

    parts := strings.Split(line, " ")
    if len(parts) != 3 {
        return nil, fmt.Errorf("malformed request line: %q", line)
    }

    req := &Request{
        Method:  parts[0],
        Path:    parts[1],
        Version: parts[2],
        Header:  make(map[string]string),
    }

    for {
        hline, err := reader.ReadString('\n')
        if err != nil {
            return nil, err
        }
        hline = strings.TrimSpace(hline)
        if hline == "" {
            break
        }
        idx := strings.Index(hline, ":")
        if idx == -1 {
            continue
        }
        name := strings.ToLower(strings.TrimSpace(hline[:idx]))
        value := strings.TrimSpace(hline[idx+1:])
        req.Header[name] = value
    }

    if cl, ok := req.Header["content-length"]; ok {
        var length int
        fmt.Sscanf(cl, "%d", &length)
        if length > 0 {
            body := make([]byte, length)
            if _, err := io.ReadFull(reader, body); err != nil {
                return nil, err
            }
            req.Body = body
        }
    }

    return req, nil
}

func handleRequest(req *Request) *Response {
    switch {
    case req.Method == "GET" && req.Path == "/":
        return &Response{
            StatusCode: 200,
            Body:       []byte("Hello from tiny HTTP server\n"),
        }
    case req.Method == "GET" && req.Path == "/hello":
        return &Response{
            StatusCode: 200,
            Body:       []byte("Hello, world!\n"),
        }
    case req.Method == "POST" && req.Path == "/echo":
        return &Response{
            StatusCode: 200,
            Header: map[string]string{
                "Content-Type": "text/plain; charset=utf-8",
            },
            Body: req.Body,
        }
    default:
        return &Response{
            StatusCode: 404,
            Body:       []byte("404 page not found\n"),
        }
    }
}

func writeResponse(w *bufio.Writer, resp *Response) error {
    if resp.StatusCode == 0 {
        resp.StatusCode = 200
    }
    if resp.StatusText == "" {
        resp.StatusText = statusText(resp.StatusCode)
    }

    if _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n", resp.StatusCode, resp.StatusText); err != nil {
        return err
    }

    if resp.Header == nil {
        resp.Header = make(map[string]string)
    }
    if _, ok := resp.Header["Content-Length"]; !ok {
        resp.Header["Content-Length"] = fmt.Sprintf("%d", len(resp.Body))
    }
    if _, ok := resp.Header["Content-Type"]; !ok {
        resp.Header["Content-Type"] = "text/plain; charset=utf-8"
    }

    for k, v := range resp.Header {
        if _, err := fmt.Fprintf(w, "%s: %s\r\n", k, v); err != nil {
            return err
        }
    }

    if _, err := w.WriteString("\r\n"); err != nil {
        return err
    }

    if len(resp.Body) > 0 {
        if _, err := w.Write(resp.Body); err != nil {
            return err
        }
    }

    return w.Flush()
}

func statusText(code int) string {
    switch code {
    case 200:
        return "OK"
    case 404:
        return "Not Found"
    case 500:
        return "Internal Server Error"
    default:
        return "Status"
    }
}

9. Trying It Out

Run:

go run .

Then:

curl -v http://localhost:8080/
curl -v http://localhost:8080/hello
curl -v -X POST http://localhost:8080/echo -d "Hi tiny server"

You’ll see:

  • Responses from your hand-crafted server
  • Logs of parsed requests in your terminal

10. Where to Go Next

From here, you can:

  • Add query string parsing
  • Support HTTP/1.0 quirks
  • Implement chunked encoding for responses
  • Add TLS by wrapping net.Conn with crypto/tls
  • Build a mini framework that wraps this API

Keep Reading

Follow the engineering thread

Get the next practical Birdor note, or browse the archive for related systems, tooling, and architecture work.

Join newsletter Browse articles