🧅 Analogy
Middleware is an onion around your handler. A request travels inward through each layer — logging, authentication, rate-limiting — reaches the core handler, then the response travels back out through the same layers. Each layer is the same shape (Handler), so you can add, remove, or reorder them freely.
Routing with the standard mux
Since Go 1.22, http.ServeMux routes by method and captures path variables — so you rarely need a third-party router:
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser) // method + wildcard
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /static/", serveStatic) // trailing / = subtree
// inside getUser: id := r.PathValue("id")
The matching rules: a request matches the most specific pattern (a fixed path beats a wildcard); a method-specific pattern (GET /x) beats a method-less one (/x); a trailing-slash pattern (/static/) matches the whole subtree; and {name...} captures the rest of the path. A request that matches a path but not its method gets an automatic 405 Method Not Allowed.
Middleware wraps a handler
A middleware takes a Handler and returns a Handler — so they compose by nesting. Run it all in-process with a recorder:
graph LR REQ["request"] --> L["logging"] --> A["auth"] --> M["mux → handler"] M --> A2["auth (return)"] --> L2["logging (return)"] --> RES["response"]
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// middleware: same shape in, same shape out
func withRequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-ID", "req-123")
next.ServeHTTP(w, r) // call the wrapped handler
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s", r.PathValue("id")) // path variable
})
handler := withRequestID(mux) // wrap the whole mux in one line
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest("GET", "/users/42", nil))
fmt.Println("status: ", rec.Code) // 200
fmt.Println("request-id:", rec.Header().Get("X-Request-ID")) // req-123
fmt.Println("body: ", rec.Body.String()) // user 42
}
Chaining middleware, and the order
Real apps stack several. A tiny chain helper folds a slice so the first-listed runs first (outermost). This playground records the actual entry/exit order so you can see the onion unwind:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type Middleware func(http.Handler) http.Handler
// chain applies middlewares so mw[0] is the OUTERMOST (runs first).
func chain(h http.Handler, mw ...Middleware) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
func trace(name string, log *[]string) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*log = append(*log, "→"+name)
next.ServeHTTP(w, r)
*log = append(*log, "←"+name)
})
}
}
func main() {
var log []string
final := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log = append(log, "handler")
})
h := chain(final, trace("log", &log), trace("auth", &log))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
fmt.Println(strings.Join(log, " "))
// →log →auth handler ←auth ←log
}
A recovery middleware
The most important middleware to write first: one that recovers from a panic so a single bad handler can’t crash the server. It defers a recover(), logs, and returns a 500:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
fmt.Println("recovered panic:", rec) // in real code: log it
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
boom := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("handler blew up")
})
rec := httptest.NewRecorder()
recoverer(boom).ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
fmt.Println("status:", rec.Code) // 500 — server survives
}
Reference
| Concern | How |
|---|---|
| Route by method+path | mux.HandleFunc("GET /users/{id}", h) |
| Path variable | r.PathValue("id") |
| Subtree route | trailing / ("/static/") |
| Catch-all wildcard | {rest...} |
| Middleware type | func(http.Handler) http.Handler |
| Apply one | h = mw(h) |
| Apply many (first = outer) | chain(h, a, b, c) |
| Must-have middleware | recovery, request logging, auth |
🐹 Middleware is just composition — keep it boring
The whole pattern is func(http.Handler) http.Handler. Resist a framework until the standard mux genuinely can’t express your routes. Common middlewares: panic recovery (write this first), request logging, auth, CORS, gzip, and rate limiting. Put cross-cutting concerns here and keep handlers focused on one job. Mind the order — recovery should be outermost so it catches panics from everything inside it.
See also
- HTTP server — handlers, the mux, and
httptest. - REST APIs — applying these handlers to JSON resources.
- web security — security-header and rate-limit middleware.
- panic & recover — the mechanism behind recovery middleware.
Next: securing it over the wire — TLS & HTTPS.
Related topics
Serving HTTP in net/http — handlers and HandlerFunc, the ResponseWriter and Request, the Go 1.22 method+pattern ServeMux with PathValue, decoding request bodies, and a production-shaped http.Server with timeouts.
apisBuilding REST APIsJSON over HTTP done right — resources and methods, idempotency, decoding/validating requests and encoding responses, the status codes that matter, consistent error envelopes, and versioning.
httpWeb SecurityDefensive defaults for Go web services — security headers and HSTS, parameterized queries against SQL injection, html/template auto-escaping against XSS, body-size limits, constant-time secret comparison, and never leaking internal errors.
Check your understanding
Score: 0 / 51. What is middleware in Go's net/http?
Middleware is just `func(next http.Handler) http.Handler`. It returns a handler that does something (log, auth, set a header), then calls next.ServeHTTP — composable because the input and output are the same interface.
2. In Go 1.22+, what does the pattern "GET /users/{id}" give you?
Since Go 1.22 the standard ServeMux understands method prefixes and {name} wildcards. It matches the method and captures the segment, available via r.PathValue("id") — no third-party router needed for most apps.
3. In what order do wrapped middlewares run for `log(auth(mux))`?
The outermost wrapper sees the request first. log's handler runs, calls auth's handler, which calls the mux; responses unwind back out through auth then log — like nested function calls, because that's exactly what they are.
4. A chain helper applies middlewares left-to-right as chain(h, A, B, C). For A to be the OUTERMOST layer, how must it wrap?
To make the first-listed middleware outermost (run first), fold the slice from the end: for i := len(mw)-1; i>=0; i-- { h = mw[i](h) }. Then A is applied last, ending up as the outermost wrapper that sees the request first.
5. Why is a recovery middleware important?
Each request runs in its own goroutine, and an unrecovered panic there terminates the process. A recovery middleware defers a recover(), logs the panic, and writes http.StatusInternalServerError — so one bad request can't take the server down. (net/http's own server recovers per-request, but explicit middleware lets you log and shape the response.)
Comments
Sign in with GitHub to join the discussion.