Control Flow in HTTP Middleware
HTTP handlers run concurrently under load, so a single panic must not crash the process.
Search across all documentation pages
HTTP handlers run concurrently under load, so a single panic must not crash the process.
Middleware wraps handlers with defer/recover, shapes request-scoped control flow, and decides whether to call the next handler - patterns shared across chi, gin, echo, and the stdlib net/http.
Wrap each request handler so a deferred recover logs the panic and returns HTTP 500.
Use defer for per-request timers, body close, and tracing spans.
Middleware returns early by not calling next, or by writing a response and returning.
Panic recovery belongs at the outermost useful layer - usually server or router middleware.
Pair recovery with structured logging and request IDs from context.
Quick-reference recipe card - copy-paste ready.
package main
import (
"log"
"net/http"
)
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v path=%s", rec, r.URL.Path)
http.Error(w, http.StatusText(http.StatusInternalServerError),
http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func hello(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/hello", hello)
http.ListenAndServe(":8080", recoverMiddleware(mux))
}When to reach for this:
defer to close request bodies and end observability spans per request.next.package main
import (
"context"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func timeoutMiddleware(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer) // chi's defer/recover middleware
r.Use(timeoutMiddleware(2 * time.Second))
r.Get("/boom", func(w http.ResponseWriter, r *http.Request) {
panic("developer mistake")
})
r.Get("/ok", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("fine"))
})
log.Println(http.ListenAndServe(":8080", r))
}What this demonstrates:
Recoverer applies the standard defer/recover pattern across all routes.timeoutMiddleware defers cancel() to release timer resources when the request finishes.r.WithContext without globals./boom becomes 500; /ok still serves normally.net/http; panic unwinds that goroutine only.http.Handler - control passes linearly unless middleware returns early.defer recover must sit in the same function that invokes the handler (or calls next.ServeHTTP).Recovery() middleware with similar defer stacks and optional stack traces.recover layers are harmless but redundant - pick one outer recovery.Request ─► Logging ─► Recover ─► Auth ─► Handler
│ │ │
│ │ └─ return 401 (skip handler)
│ └─ defer recover on panic
└─ defer log latency
| Framework | Recovery helper | Notes |
|---|---|---|
| stdlib | hand-written defer recover | Full control, no dependency |
| chi | middleware.Recoverer | Plugs into r.Use chain |
| gin | gin.Recovery() | Default engine includes logger + recovery |
| echo | middleware.Recover() | Configurable stack trace logging |
// Auth short-circuit: do not call next when unauthorized
func auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}debug.Stack().Use chain.r.Body leaks connections on errors. Fix: defer r.Body.Close() in handlers or shared middleware after routing.http.Error with proper status for expected failures.r.Context() in I/O calls or timeouts never fire. Fix: pass context to http.NewRequestWithContext, database calls, and gRPC stubs.| Alternative | Use When | Don't Use When |
|---|---|---|
Framework Recovery() middleware | Fast bootstrap with gin/echo/chi | You need custom panic metrics shape |
http.Server panic in handler | Never for production | - |
| Process supervisor only | Batch workers | Long-lived HTTP servers |
| Error returns instead of panic | Business validation | - |
| gRPC interceptors for unary RPC | gRPC services | Plain HTTP handlers |
Outside the handler, typically early in the chain.
Outer placement catches panics from inner middleware too.
No - it converts an unwinding panic into a 500 response.
The client must retry; the server goroutine continues serving other requests.
Yes - close bodies, flush buffers, end spans.
Keep defers cheap; avoid heavy work in defer on hot paths.
Gin logs stack and returns 500 similar to custom middleware.
Tune with gin.RecoveryWithWriter for log routing.
Echo middleware can disable or customize stack output.
Point it at structured logs in production.
Rarely - middleware is linear, not nested loops.
Use return to short-circuit.
Read from chi middleware.GetReqID or set in custom middleware into context.
Include ID in panic logs.
Yes - unary/stream interceptors mirror HTTP recovery.
Use google.golang.org/grpc recovery patterns for RPC servers.
Only if handlers respect r.Context().Done().
Blocking calls without context ignore timeouts.
Startup panics fail fast before accepting traffic.
Runtime request paths should not panic for user input.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026