Control Flow in HTTP Middleware
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.
Summary
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.
Recipe
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:
- Add panic recovery on any public HTTP server before shipping.
- Use
deferto close request bodies and end observability spans per request. - Short-circuit auth middleware by writing 401 and returning without
next. - Stack multiple middlewares: logging outside, recovery outside-most or just inside logging depending on desired stack traces.
Working Example
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:
- Chi's
Recovererapplies the standard defer/recover pattern across all routes. timeoutMiddlewaredeferscancel()to release timer resources when the request finishes.- Context flows to handlers via
r.WithContextwithout globals. - Panic on
/boombecomes 500;/okstill serves normally.
Deep Dive
How It Works
- Each request runs in its own goroutine (or worker) from
net/http; panic unwinds that goroutine only. - Middleware is a higher-order function wrapping
http.Handler- control passes linearly unless middleware returns early. defer recovermust sit in the same function that invokes the handler (or callsnext.ServeHTTP).- Frameworks (gin, echo) expose
Recovery()middleware with similar defer stacks and optional stack traces. - Double
recoverlayers are harmless but redundant - pick one outer recovery.
Middleware Control Flow
Request ─► Logging ─► Recover ─► Auth ─► Handler
│ │ │
│ │ └─ return 401 (skip handler)
│ └─ defer recover on panic
└─ defer log latency
Framework Notes
| 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 |
Go Notes
// 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)
})
}Gotchas
- Recover without logging context - Panics become silent 500s. Fix: log request ID, path, method, and
debug.Stack(). - Writing response then calling next - Double response writes panic or corrupt output. Fix: return immediately after writing error response.
- Recover inside handler only - Panics in outer middleware still crash if unrecovered. Fix: place recovery early in the
Usechain. - Defer body close missing -
r.Bodyleaks connections on errors. Fix:defer r.Body.Close()in handlers or shared middleware after routing. - Using panic for validation errors - Clients get 500 instead of 4xx. Fix: return
http.Errorwith proper status for expected failures. - Timeout context ignored downstream - Handlers must accept
r.Context()in I/O calls or timeouts never fire. Fix: pass context tohttp.NewRequestWithContext, database calls, and gRPC stubs.
Alternatives
| 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 |
FAQs
Where should recover sit in the middleware stack?
Outside the handler, typically early in the chain.
Outer placement catches panics from inner middleware too.
Does recover restart the request?
No - it converts an unwinding panic into a 500 response.
The client must retry; the server goroutine continues serving other requests.
Should handlers use defer for response cleanup?
Yes - close bodies, flush buffers, end spans.
Keep defers cheap; avoid heavy work in defer on hot paths.
How does gin Recovery differ from hand-written?
Gin logs stack and returns 500 similar to custom middleware.
Tune with gin.RecoveryWithWriter for log routing.
What about echo Recover config?
Echo middleware can disable or customize stack output.
Point it at structured logs in production.
Can middleware use labeled break?
Rarely - middleware is linear, not nested loops.
Use return to short-circuit.
How do I propagate request IDs?
Read from chi middleware.GetReqID or set in custom middleware into context.
Include ID in panic logs.
Should gRPC use panic recover too?
Yes - unary/stream interceptors mirror HTTP recovery.
Use google.golang.org/grpc recovery patterns for RPC servers.
Does timeout middleware cancel handlers?
Only if handlers respect r.Context().Done().
Blocking calls without context ignore timeouts.
Is it OK to panic on misconfiguration at startup?
Startup panics fail fast before accepting traffic.
Runtime request paths should not panic for user input.
Related
- defer, panic & recover - defer stack and recover rules
- Control Flow Basics - minimal recover example
- break, continue & Labeled Control Flow - short-circuit via return in middleware
- HTTP Servers, Handlers & ServeMux Patterns - stdlib server wiring
- Control Flow Best Practices - readable handler flow
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).