17 / 20 · Day 6
Day 6 · Concept 17

net/http

Five lines for a production-grade HTTP server. The whole module is built around one interface (Handler with one method) and one function-to-interface adapter (HandlerFunc). Middleware is just functions wrapping functions. This is what made Go a default backend language for the cloud era.


1 · The whole API in 50 lines

go net/http · the core types
// The handler — one method
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

// Function adapter — every "handler function" you write satisfies Handler.
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

// Mux = router. Implements Handler itself.
type ServeMux struct { ... }
func (m *ServeMux) Handle(pattern string, h Handler)
func (m *ServeMux) HandleFunc(pattern string, f func(ResponseWriter, *Request))

// Start a server
func ListenAndServe(addr string, h Handler) error

Everything else — routing, middleware, mux, security headers, TLS, HTTP/2 — either implements Handler or composes one. That's the design.

2 · Try it — the five-line server

go main.go · a real HTTP server
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "hello, %s!", r.URL.Path[1:])
    })
    http.ListenAndServe(":8080", nil)
}
// curl localhost:8080/world  →  hello, world!

Five lines, one binary, production-capable. HTTPS, HTTP/2 with TLS, keep-alive, pipelining, content compression — all built in. You add observability and middleware yourself.

3 · Routing with the 1.22+ mux

Since Go 1.22, the built-in ServeMux supports method-and-path routing — Gin/Echo levels of expressiveness in the standard library.

go main.go · the modern mux
package main

import (
    "fmt"
    "net/http"
)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /users", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "list users")
    })

    mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "create user")
    })

    mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        fmt.Fprintln(w, "user:", id)
    })

    http.ListenAndServe(":8080", mux)
}

4 · Middleware — function composition

A middleware is a function that takes a Handler and returns a Handler. That's it. No framework, no annotation, no DI container.

go main.go · logging middleware
package main

import (
    "fmt"
    "log"
    "net/http"
    "time"
)

// Middleware: wrap a Handler in a Handler
func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s — %v", r.Method, r.URL.Path, time.Since(start))
    })
}

func recoverPanics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                http.Error(w, "internal error", 500)
                log.Printf("panic: %v", err)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "hi")
    })

    // Compose: logging(recoverPanics(mux)) — outermost runs first
    http.ListenAndServe(":8080", logging(recoverPanics(mux)))
}

5 · The client

go main.go · http.Get and friends
package main

import (
    "fmt"
    "io"
    "net/http"
    "strings"
    "time"
)

func main() {
    client := &http.Client{Timeout: 10 * time.Second}

    // GET
    resp, err := client.Get("https://httpbin.org/get")
    if err != nil { panic(err) }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("status: %d
", resp.StatusCode)
    fmt.Printf("body: %s
", body[:50])

    // POST with body
    resp2, _ := client.Post(
        "https://httpbin.org/post",
        "text/plain",
        strings.NewReader("hello"),
    )
    defer resp2.Body.Close()
    fmt.Println("POST status:", resp2.StatusCode)
}
Always set Timeout. The default http.Get uses http.DefaultClient with NO timeout — a slow server hangs your goroutine forever. Always use a *http.Client with explicit timeouts in production.

6 · JSON request handler

go main.go · POST JSON, return JSON
package main

import (
    "encoding/json"
    "net/http"
)

type Input struct {
    Name string `json:"name"`
}
type Output struct {
    Greeting string `json:"greeting"`
}

func greet(w http.ResponseWriter, r *http.Request) {
    var in Input
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    out := Output{Greeting: "Hello, " + in.Name + "!"}
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(out)
}

func main() {
    http.HandleFunc("POST /greet", greet)
    http.ListenAndServe(":8080", nil)
}

7 · From the wild

go prometheus · the entire metrics endpoint
// Tiny: Prometheus exposes metrics via a single Handler.
http.Handle("/metrics", promhttp.Handler())
log.Fatal(http.ListenAndServe(":8080", nil))

// Every prometheus-instrumented Go binary in production runs roughly this.
// The Handler returned by promhttp.Handler() reads from the metrics registry
// on every scrape, encodes them in the Prometheus exposition format, writes
// to the ResponseWriter. ~100 LOC of actual handler code, end to end.
From the wild: github.com/prometheus/client_golang · Apache 2.0

8 · Common mistakes

  • Not closing the response body. defer resp.Body.Close() right after the err check. Otherwise: connection pool leak.
  • Using http.DefaultClient in production. No timeout = potential outage. Always make your own *http.Client.
  • Writing the header after the body. w.WriteHeader(200) must come before the first Write. The first Write implicitly writes 200 if you didn't.
  • Not reading the body before close. If you close without reading, the connection can't be reused. io.Copy(io.Discard, resp.Body) before close for short bodies you'll throw away.
  • Storing the *Request in a goroutine after handler returns. The body is closed by then. Copy what you need synchronously.

9 · Exercises (~15 min)

  1. Echo server. Build one that returns the request body. io.Copy(w, r.Body).
  2. Middleware chain. Add logging + panic recovery + a header injector. Chain three middlewares.
  3. Reverse proxy. Forward to https://httpbin.org/anything using httputil.ReverseProxy. Five lines.
  4. Health check. Add a /healthz endpoint. Make it return 200 if a connection to an external service succeeds, 503 otherwise.

10 · When it clicks

  • You'll build the next backend prototype in net/http first, choose a framework only if you outgrow it.
  • You reach for middleware chains over framework annotations.
  • You always set a timeout on every HTTP client.
  • You defer the body close on every response without thinking.
Found this useful?