rgoussu@goussu: ~/library/go/web-frameworks
~/library/go/web-frameworks cat net-http.md

net/http deep dive

# The stdlib HTTP stack in depth — Handler contract, 1.22 ServeMux patterns, middleware, server timeouts, client pooling, HTTP/2, and graceful shutdown.

Conceptsaved 2026-08-09 #go#web#http#frameworks#stdlib#middleware

Overview

net/http is the reason Go has thin routers instead of heavy frameworks: a complete, production-grade HTTP server and client in the standard library. Its single-method http.Handler interface is the interoperability point of the whole ecosystem — every router, middleware, and observability wrapper that honors it composes with every other. Since Go 1.22 the built-in ServeMux also handles method matching and path wildcards, covering what most services previously imported a router for.

Key points

  • The contract: type Handler interface { ServeHTTP(ResponseWriter, *Request) }, with http.HandlerFunc adapting a plain function to it. Everything else is built from this.
  • 1.22 ServeMux patterns: "GET /items/{id}" matches by method; {id} captures a segment (read with r.PathValue("id")); {path...} captures the remainder; a trailing {$} matches exactly /. The most specific registered pattern wins; genuinely ambiguous overlaps panic at registration.
  • Middleware = decorators: func(http.Handler) http.Handler, wrapped outermost-first. No framework machinery — just function composition.
  • Context flows through the request: r.Context() carries cancellation (client gone, server shutdown) and request-scoped values; derive with r.WithContext(ctx). Keys should be unexported custom types, and values reserved for cross-cutting data (request ID, auth principal) — not a general parameter bag.
  • Set server timeouts — the zero-value http.Server never times anything out. At minimum ReadHeaderTimeout (Slowloris defence); usually ReadTimeout, WriteTimeout, IdleTimeout too.
  • http.Client misuse is the classic footgun: the zero-value client has no timeout, and creating a client (or Transport) per request defeats connection pooling. Build one client, reuse it, and always fully read and close response bodies so connections return to the pool.
  • HTTP/2 is automatic over TLS; cleartext h2c needs explicit opt-in (historically via golang.org/x/net/http2/h2c, on recent releases via the server's protocol configuration).
  • Graceful shutdown: srv.Shutdown(ctx) stops accepting, drains in-flight requests, and unblocks ListenAndServe with http.ErrServerClosed.
  • Panics don't kill the server: each connection's goroutine recovers, logs, and drops the connection. Panicking with http.ErrAbortHandler aborts a response silently — the sanctioned way to bail without a stack trace in the logs.
  • chi is the idiomatic thin layer when the stdlib mux runs out: same http.Handler contract, plus sub-routers, mounting, and a curated middleware set.
  • html/template covers server-side rendering with context-aware escaping (HTML, attr, JS, URL) — safe-by-default output, unlike text/template.

Details

ServeMux precedence

Registration order is irrelevant; specificity decides. GET /items/{id} beats /items/{id} (method-specific wins), and /items/latest beats /items/{id} (literal beats wildcard). GET patterns also match HEAD. Two patterns where neither is more specific — e.g. /a/{x}/c vs /a/b/{y} — conflict and panic at Handle time, which surfaces route bugs at startup rather than in production traffic.

Server hygiene

srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       120 * time.Second,
}

Per-route deadlines that must outlive WriteTimeout (long polls, streaming) are better handled with http.TimeoutHandler or context deadlines than by loosening global knobs. For shutdown, catch SIGTERM, call srv.Shutdown(ctx) with a drain budget, and only then release dependencies (DB pools, queues).

Client hygiene

http.Transport owns the connection pool: MaxIdleConns, MaxIdleConnsPerHost (default 2 — raise it for high fan-out to one host), TLS session caching. Client.Timeout bounds the entire exchange including body read; per-attempt budgets belong to context.WithTimeout on the request. A body that is closed without being drained may discard the connection instead of pooling it.

chi on top

chi keeps every handler and middleware stdlib-shaped, adding r.Route/r.Mount for grouped sub-routers and chi/middleware (RequestID, RealIP, Recoverer, Timeout, Compress). Because nothing wraps the contract, otelhttp, promhttp and any func(http.Handler) http.Handler slot in unchanged — the property the correspondence table is about.

Examples

Middleware plus 1.22 mux patterns, no third-party imports:

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request", "method", r.Method, "path", r.URL.Path,
            "dur", time.Since(start))
    })
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        json.NewEncoder(w).Encode(map[string]string{"id": id})
    })
    mux.HandleFunc("GET /static/{path...}", serveStatic)
    log.Fatal(http.ListenAndServe(":8080", logging(mux)))
}

Related