HTTP Servers, Handlers & ServeMux Patterns
net/http routes requests through ServeMux to Handler implementations.
Go 1.22 modernized pattern matching so method-aware REST routes work without a third-party router.
Summary
An HTTP server listens on a port, parses requests, and dispatches them to handlers.
http.Server wraps the listener with timeout and TLS knobs.
http.ServeMux maps host, method, and path patterns to handlers.
Handlers implement ServeHTTP or use the HandlerFunc adapter.
Middleware wraps handlers to add logging, auth, and metrics before your business logic runs.
Recipe
Quick-reference recipe card - copy-paste ready.
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("GET /api/users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, `{"id":%q}`, id)
})
srv := &http.Server{Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
log.Fatal(srv.ListenAndServe())When to reach for this:
- Building REST APIs with explicit method and path rules on Go 1.22+.
- Services that must stay stdlib-only without chi or gorilla/mux.
- Sidecars and admin ports that need a handful of well-defined routes.
Working Example
package main
import (
"encoding/json"
"log"
"net/http"
"strings"
"time"
)
type user struct {
ID string `json:"id"`
Name string `json:"name"`
}
var users = map[string]user{
"1": {ID: "1", Name: "Ada"},
"2": {ID: "2", Name: "Lin"},
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("GET /api/users/{id}", getUser)
mux.HandleFunc("POST /api/users", createUser)
api := stripPrefix("/api", mux)
srv := &http.Server{
Addr: ":8080",
Handler: logging(api),
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
u, ok := users[id]
if !ok {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(u)
}
func createUser(w http.ResponseWriter, r *http.Request) {
var in user
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
in.ID = "3"
users[in.ID] = in
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(in)
}
func stripPrefix(prefix string, h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, prefix)
if p == "" {
p = "/"
}
r2 := r.Clone(r.Context())
r2.URL.Path = p
h.ServeHTTP(w, r2)
})
}
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 %s", r.Method, r.URL.Path, time.Since(start))
})
}What this demonstrates:
- Method-specific patterns on a dedicated
ServeMux. r.PathValuefor{id}segments.- Prefix stripping to mount a sub-router at
/api. - Production-oriented
http.Servertimeout fields. - Logging middleware wrapping the entire API tree.
Deep Dive
How It Works
- The listener accepts TCP connections; each connection may serve many keep-alive requests.
ServeMuxmatches the longest applicable pattern considering method and host rules.- The winning handler's
ServeHTTPruns on the connection's goroutine unless you spawn work yourself. ResponseWriterbuffers headers until the firstWriteor explicitWriteHeader.
Pattern Syntax (Go 1.22+)
| Pattern | Matches |
|---|---|
/files/ | Any method, paths under /files/ |
GET /users/{id} | GET only, captures id |
GET /items/{id}/ | GET with trailing slash segment |
{$} | Catch-all for unmatched paths (use carefully) |
Go Notes
// Avoid registering on the global mux in libraries:
// http.HandleFunc(...) // bad in reusable packages
mux := http.NewServeMux() // good: local, testable
// Host-scoped routes:
mux.HandleFunc("GET example.com/admin/", adminHandler)Handler Types
| Type | Use when |
|---|---|
http.HandlerFunc | Simple function handlers |
Struct with ServeHTTP | Handlers needing deps (DB, config) |
http.FileServer | Static assets |
http.StripPrefix + inner mux | Mount sub-apps |
Gotchas
- Using
http.DefaultServeMuxin libraries - Third-party imports can register conflicting routes. Fix: Passhttp.NewServeMux()frommain. - Forgetting
ReadHeaderTimeout- Slow clients can hold connections without sending headers. Fix: Set it on every productionhttp.Server. - Writing body before status on errors - First
Writelocks status to 200. Fix: CallWriteHeaderorhttp.Errorbefore body bytes. - Assuming path params work on Go 1.21 -
r.PathValuerequires Go 1.22+ patterns. Fix: Upgrade Go or use a router like chi. - Trailing slash mismatches -
/usersand/users/are different patterns. Fix: Register both or redirect consistently. - Spawning unbounded goroutines per request - Can exhaust memory under load. Fix: Use worker pools or bound concurrency with semaphores.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| chi | Want route groups and middleware stacks on stdlib handlers | You need zero dependencies |
| gorilla/mux | Regex routes and host constraints are central | Go 1.22 patterns already suffice |
| gin / echo | Binding, validation, and large middleware ecosystems | You want minimal magic and stdlib types |
Raw ServeMux | Small API, few routes, stdlib only | Complex regex or multi-tenant host routing |
FAQs
What changed in Go 1.22 ServeMux?
Patterns can include HTTP methods and {name} wildcards.
r.PathValue reads captured segments.
Older prefix-only matching still works for unqualified patterns.
How do I handle 405 Method Not Allowed?
Register separate patterns per method.
Unregistered method/path pairs return 404; explicit method checks inside a catch-all handler can return 405.
Can one server listen on multiple ports?
Create multiple http.Server values sharing the same handler, each with its own ListenAndServe goroutine.
How do I serve static files?
Use mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("public")))).
Set cache headers in wrapping middleware if needed.
Should handlers be methods on structs?
Yes when handlers need dependencies like *sql.DB.
Store deps on the struct and implement ServeHTTP once per route type.
How does Host matching work?
Patterns may include a host prefix like api.example.com/.
Useful when one process serves multiple virtual hosts.
What is http.MaxBytesHandler?
It wraps a handler and limits request body bytes read.
Pair with http.MaxBytesReader for JSON endpoints accepting POST bodies.
How do I test ServeMux routing?
Use httptest.NewRequest with method and path, then mux.ServeHTTP(rec, req).
Assert status and body with httptest.ResponseRecorder.
Does ServeHTTP run concurrently?
Yes - each request typically runs on its own goroutine.
Protect shared handler state with mutexes or confine state to request scope.
How do I redirect HTTP to HTTPS?
Run a second listener on :80 that returns http.Redirect to TLS URLs, or terminate TLS at the load balancer.
Can I mount gRPC and HTTP on one port?
Not with plain ServeMux.
Use separate ports, cmux, or a gateway that routes by protocol.
What is the difference between Handle and HandleFunc?
Handle registers an http.Handler.
HandleFunc registers a function via the HandlerFunc adapter.
Related
- net/http Basics - introductory snippets
- Middleware Chains & Request Context - compose wrappers
- HTTP/2, Timeouts & ReverseProxy - server timeouts and HTTP/2
- Production net/http Configuration Checklist - deploy checklist
- httptest for Handler Integration Tests - test routing
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).