Middleware Chains & Request Context
Middleware wraps http.Handler to run cross-cutting logic before and after your route handlers.
Request-scoped metadata flows through r.Context() using typed, unexported context keys.
Summary
A middleware function accepts an inner handler and returns a new handler that delegates to it.
Chains nest wrappers: the outermost middleware sees the request first and the response last.
r.Context() is the standard carrier for cancellation, deadlines, and request metadata.
Use r.WithContext to attach values immutably; never mutate the request in place without cloning.
Libraries like chi provide Use helpers, but the underlying model is identical to stdlib composition.
Recipe
Quick-reference recipe card - copy-paste ready.
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = uuid.NewString()
}
ctx := context.WithValue(r.Context(), reqIDKey{}, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}When to reach for this:
- Adding logging, auth, metrics, and recovery uniformly across routes.
- Propagating request IDs and principal info to downstream calls.
- Building framework-free services that still need cross-cutting concerns.
Working Example
package main
import (
"context"
"log"
"net/http"
"time"
)
type ctxKey int
const userKey ctxKey = 1
type statusWriter struct {
http.ResponseWriter
code int
}
func (w *statusWriter) WriteHeader(code int) {
w.code = code
w.ResponseWriter.WriteHeader(code)
}
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func recoverer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
log.Printf("panic: %v", v)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, "demo-user")
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func accessLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusWriter{ResponseWriter: w, code: http.StatusOK}
start := time.Now()
next.ServeHTTP(sw, r)
log.Printf("%s %s %d %s", r.Method, r.URL.Path, sw.code, time.Since(start))
})
}
func userFrom(ctx context.Context) (string, bool) {
u, ok := ctx.Value(userKey).(string)
return u, ok
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
user, ok := userFrom(r.Context())
if !ok {
http.Error(w, "no user", http.StatusInternalServerError)
return
}
w.Write([]byte(user))
})
handler := chain(mux, recoverer, authenticate, accessLog)
http.ListenAndServe(":8080", handler)
}What this demonstrates:
- Explicit middleware chain construction order.
- Auth middleware short-circuits with 401 before inner handlers run.
- Typed private context key for user identity.
statusWriterwrapper capturing HTTP status for logs.- Panic recovery at the outermost layer.
Deep Dive
How It Works
- Each middleware returns a new
http.Handlerclosure over the inner handler. - Short-circuiting skips inner handlers by returning early without calling
next.ServeHTTP. r.WithContextreturns a shallow copy of the request with a new context tree.- Downstream code reads values with typed accessors, not raw string keys.
Middleware Ordering
Request -->
recoverer -->
authenticate -->
accessLog -->
mux / business handler
Response <--
Outer middleware should include recovery and tracing; auth sits inside recovery but outside business logic.
Go Notes
// chi equivalent (stdlib handlers underneath):
// r.Use(middleware.Recoverer, middleware.RequestID)
// r.Get("/me", handler)
// Prefer accessors:
func User(ctx context.Context) (string, bool) {
u, ok := ctx.Value(userKey).(string)
return u, ok
}Context Value Rules
| Do | Don't |
|---|---|
| Unexported key types in owning package | String keys in libraries |
| Request IDs, auth principal, locale | Large structs or optional params |
| Document keys in package comment | Store *sql.DB in context |
Gotchas
- Wrong middleware order - Logging inside auth may leak unauthenticated paths; recovery must be outermost. Fix: Document order: recover -> trace -> auth -> log -> routes.
- String context keys - Collide across packages silently. Fix: Use private
type ctxKey intconstants. - Storing request in context - Creates circular references and confuses lifetimes. Fix: Store only the few values handlers need.
- Mutating
r.URLwithout Clone - Race when middleware shares requests. Fix: User.Clone(ctx)when altering URL or headers for sub-routers. - Forgetting to call
next.ServeHTTP- Silently drops requests. Fix: Lint or test middleware with httptest asserting body reached. - Passing
context.Background()downstream - Loses cancel on client disconnect. Fix: Start fromr.Context()for all child contexts.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
chi Use | Cleaner middleware registration | Zero-deps stdlib only |
gin Use / echo Pre | Framework-native binding and groups | You need plain http.Handler exports |
| Service mesh headers | Cross-service identity propagation | Single monolith with in-process auth |
| Manual per-handler checks | One or two routes | Cross-cutting rules duplicated everywhere |
FAQs
How do I build a middleware chain?
Apply wrappers from inside-out: last middleware in the list sits closest to the route handler.
A chain helper reversing a slice keeps call sites readable.
Where should panic recovery live?
Outermost layer so every inner middleware and handler is covered.
Log stack traces server-side; return generic 500 to clients.
How do context values differ from handler arguments?
Context carries cross-cutting metadata across layers that do not share a struct.
Business inputs belong in typed handler parameters or request bodies.
Can middleware modify the response after the handler runs?
Yes - code after next.ServeHTTP runs on the way out.
Capturing status requires wrapping ResponseWriter before calling next.
How does this relate to r.Context() cancellation?
Always derive child contexts from r.Context(), not Background().
Middleware may attach shorter deadlines for internal sub-calls.
Should I use jwt middleware libraries?
They are fine if they still expose http.Handler and document context keys.
Verify token validation errors short-circuit before handlers.
How do I test middleware in isolation?
Create a stub inner handler that sets a header or writes a marker.
Drive the middleware with httptest.NewRequest and assert short-circuit behavior.
What about middleware per route group?
Mount sub-muxes with different wrapped handlers: http.StripPrefix plus distinct chains.
chi route groups solve this with Route blocks.
Can I pass values via headers instead of context?
Headers work for inbound edge metadata.
Context keeps in-process accessors type-safe without exposing internal headers downstream.
How many middleware layers are too many?
There is no hard limit, but deep stacks obscure ordering bugs.
Prefer a documented standard stack plus route-specific wrappers sparingly.
Does gin context replace Go context?
Gin carries its own *gin.Context, but c.Request.Context() remains the stdlib cancel root.
Sync important values into context.Context for shared libraries.
How do I skip auth for health checks?
Register /health on a separate mux without auth middleware, or teach auth middleware to allowlist paths.
Related
- HTTP Servers, Handlers & ServeMux Patterns - mount wrapped mux
- Cancellation Propagation in HTTP Handlers - ctx propagation
- Context Values: When and When Not - value rules
- Middleware & Decorator Patterns - general pattern
- net/http Basics - simple logging middleware
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).