Middleware Patterns

Updated

September 8, 2026

Middleware Patterns

Overview

Middleware wraps http.Handler to add cross-cutting behavior: logging, auth, recovery, request IDs, CORS, metrics. Keep each piece small and composable.

The shape

type Middleware func(http.Handler) http.Handler

func chain(h http.Handler, ms ...Middleware) http.Handler {
    for i := len(ms) - 1; i >= 0; i-- {
        h = ms[i](h)
    }
    return h
}
request → recover → requestID → log → auth → mux → handler
response ←─────────────────────────────────────────────

Order matters: recover outermost so panics in other middleware still convert to 500.

Recover

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                slog.Error("panic", "err", rec, "path", r.URL.Path)
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Request ID

type ctxKey int
const reqIDKey ctxKey = 1

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 = strconv.FormatInt(time.Now().UnixNano(), 36)
        }
        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), reqIDKey, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Access log with status

Capture status by wrapping ResponseWriter:

type statusWriter struct {
    http.ResponseWriter
    code int
}

func (w *statusWriter) WriteHeader(code int) {
    w.code = code
    w.ResponseWriter.WriteHeader(code)
}

func AccessLog(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        sw := &statusWriter{ResponseWriter: w, code: 200}
        start := time.Now()
        next.ServeHTTP(sw, r)
        slog.Info("http",
            "method", r.Method,
            "path", r.URL.Path,
            "status", sw.code,
            "ms", time.Since(start).Milliseconds(),
        )
    })
}

Implement Unwrap() / Flush if you need http.Hijacker or Flusher passthrough in production.

Max body

func MaxBytes(n int64) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            r.Body = http.MaxBytesReader(w, r.Body, n)
            next.ServeHTTP(w, r)
        })
    }
}

Timeout (handler budget)

func Timeout(d time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.TimeoutHandler(next, d, "timeout")
    }
}

Prefer also setting http.Server timeouts (chapter 271).

Rules of thumb

Do Don’t
One concern per middleware Mega-middleware that logs+auth+metrics+…
Document chain order Auth after handler “for speed”
Propagate context values Global request maps without locks

Try next

  1. Build a chain: Recover → RequestID → AccessLog → mux.
  2. Add unit tests with httptest asserting status on panic.
  3. Log request ID from a handler via context.