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
// 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) errorEverything 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
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.
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.
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
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)
}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
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
// 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.8 · Common mistakes
- Not closing the response body.
defer resp.Body.Close()right after the err check. Otherwise: connection pool leak. - Using
http.DefaultClientin production. No timeout = potential outage. Always make your own*http.Client. - Writing the header after the body.
w.WriteHeader(200)must come before the firstWrite. 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)
- Echo server. Build one that returns the request body.
io.Copy(w, r.Body). - Middleware chain. Add logging + panic recovery + a header injector. Chain three middlewares.
- Reverse proxy. Forward to
https://httpbin.org/anythingusinghttputil.ReverseProxy. Five lines. - Health check. Add a
/healthzendpoint. 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.