Every Go web framework ships with middleware. Chi has it. Gin has it. Echo has it. And every tutorial shows you how to use it.
But almost none of them show you how it actually works.
This article builds a production-grade middleware chain using nothing but the Go standard library. No framework, no magic. By the end you will have written request logging, authentication, panic recovery, and timeout middleware from scratch, and you will understand exactly why the pattern works the way it does.
The Foundation: What Is http.Handler?
Everything in Go's HTTP world revolves around one interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
That's it. One method. Any type that implements ServeHTTP is an HTTP handler. This simplicity is intentional, and it's what makes middleware composable.
There's also http.HandlerFunc, a function type that implements Handler:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
This adapter lets you write a plain function and treat it as a Handler. You'll use this constantly.
The Middleware Signature
A middleware is a function that takes a Handler and returns a Handler:
type Middleware func(http.Handler) http.Handler
That's the whole pattern. Middleware wraps a handler, intercepts the request, does something before or after calling the next handler, and passes control along.
Here's the skeleton every middleware follows:
func MyMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// do something before
next.ServeHTTP(w, r)
// do something after
})
}
The next handler is the thing being wrapped. You call it explicitly. This is the key insight: Go middleware is not magic. It's just nested function calls, composed at setup time.
Building the Chain
Managing middleware manually gets messy fast:
// This is readable but doesn't scale
handler := LoggingMiddleware(AuthMiddleware(RecoveryMiddleware(myHandler)))
Let's write a proper Chain that takes a slice of middleware and applies them in order:
package middleware
import "net/http"
type Middleware func(http.Handler) http.Handler
func Chain(middlewares ...Middleware) Middleware {
return func(final http.Handler) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
final = middlewares[i](final)
}
return final
}
}
The reverse iteration matters: if you want request logging to run first (outermost), it needs to wrap everything else. Applying in reverse order ensures the first middleware in your list is the first one that sees the request.
Usage:
chain := middleware.Chain(
RecoveryMiddleware,
LoggingMiddleware,
AuthMiddleware,
)
http.Handle("/api/", chain(myAPIHandler))
Clean, ordered, easy to reason about.
Middleware 1: Request Logging
The most common middleware. We want to log the method, path, status code, and duration of every request.
The tricky part: http.ResponseWriter doesn't expose the status code after it's been written. We need to wrap it:
package middleware
import (
"log/slog"
"net/http"
"time"
)
type responseWriter struct {
http.ResponseWriter
status int
written bool
}
func (rw *responseWriter) WriteHeader(code int) {
if !rw.written {
rw.status = code
rw.written = true
rw.ResponseWriter.WriteHeader(code)
}
}
func (rw *responseWriter) statusCode() int {
if !rw.written {
return http.StatusOK // default if WriteHeader was never called
}
return rw.status
}
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
wrapped := &responseWriter{ResponseWriter: w}
next.ServeHTTP(wrapped, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.statusCode(),
"duration", time.Since(start),
"remote_addr", r.RemoteAddr,
)
})
}
A few things worth noting here:
- We embed
http.ResponseWriterin our wrapper struct so all other methods (Header(),Write(), etc.) delegate to the original automatically. - We guard against double calls to
WriteHeader, which handlers can do accidentally. - We use
log/slog(Go 1.21+) for structured logging. If you're on an older version, swap in your preferred logger.
Middleware 2: Panic Recovery
A panic in a goroutine serving a request will crash the entire server if not recovered. This middleware catches panics and turns them into 500 responses:
package middleware
import (
"log/slog"
"net/http"
"runtime/debug"
)
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
slog.Error("panic recovered",
"error", err,
"stack", string(debug.Stack()),
)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
debug.Stack() gives you the full goroutine stack trace at the point of the panic, which is invaluable for debugging production incidents.
Put RecoveryMiddleware first (outermost) in your chain. It needs to wrap everything so nothing escapes.
Middleware 3: Authentication
This one reads a Bearer token, validates it, and puts the claims into the request context so downstream handlers can read them without re-doing the work.
package middleware
import (
"context"
"net/http"
"strings"
)
type contextKey string
const UserIDKey contextKey = "userID"
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
token := strings.TrimPrefix(header, "Bearer ")
userID, err := validateToken(token) // your validation logic
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Reading it in a handler
func myHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(UserIDKey).(string)
if !ok {
http.Error(w, "missing user", http.StatusInternalServerError)
return
}
// use userID
}
A few design choices worth explaining:
Why a custom contextKey type? Context keys are compared by value and type. Using a plain string like "userID" means any package could accidentally read or overwrite your value. A private contextKey type scoped to your package makes collisions impossible.
Why return early on auth failure? The middleware should short-circuit: if auth fails, call http.Error and return without calling next.ServeHTTP. Forgetting the return after the error write is a classic bug that leads to handlers running even when auth failed.
Middleware 4: Request Timeout
Long-running requests can exhaust your server's connection pool. This middleware cancels the request context after a deadline:
package middleware
import (
"context"
"net/http"
"time"
)
func TimeoutMiddleware(timeout time.Duration) Middleware {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), timeout)
defer cancel()
done := make(chan struct{})
go func() {
next.ServeHTTP(w, r.WithContext(ctx))
close(done)
}()
select {
case <-done:
// handler finished in time
case <-ctx.Done():
http.Error(w, "request timeout", http.StatusGatewayTimeout)
}
})
}
}
Note that TimeoutMiddleware takes a time.Duration and returns a Middleware. This is the factory pattern: when a middleware needs configuration, wrap it in a function that accepts that config and returns the actual middleware. Clean and composable.
Usage:
chain := middleware.Chain(
RecoveryMiddleware,
LoggingMiddleware,
middleware.TimeoutMiddleware(5*time.Second),
AuthMiddleware,
)
Putting It All Together
Here's a full, working example:
package main
import (
"fmt"
"net/http"
"time"
"yourmodule/middleware"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
userID, _ := r.Context().Value(middleware.UserIDKey).(string)
fmt.Fprintf(w, "Hello, %s\n", userID)
}
func main() {
chain := middleware.Chain(
middleware.RecoveryMiddleware,
middleware.LoggingMiddleware,
middleware.TimeoutMiddleware(5*time.Second),
middleware.AuthMiddleware,
)
mux := http.NewServeMux()
mux.Handle("/hello", chain(http.HandlerFunc(helloHandler)))
http.ListenAndServe(":8080", mux)
}
The execution order for a request to /hello:
RecoveryMiddleware (enter)
LoggingMiddleware (enter, start timer)
TimeoutMiddleware (enter, start context)
AuthMiddleware (enter, validate token)
helloHandler (run)
AuthMiddleware (exit)
TimeoutMiddleware (exit)
LoggingMiddleware (exit, log result)
RecoveryMiddleware (exit)
Each middleware wraps the next. The chain is just deeply nested function calls, resolved at request time.
When to Reach for a Framework
This pattern scales well. But there are cases where the standard library genuinely falls short:
-
Path parameters:
net/http'sServeMuxgained basic wildcard support in Go 1.22 (/users/{id}), but complex routing (regex, optional segments) still requires a router like Chi or httprouter. -
Middleware on route groups: applying middleware to
/api/but not/healthzrequires careful mux setup or a router that supports route groups natively.
For most APIs, the standard library plus a lightweight router like Chi (which uses this exact middleware pattern under the hood) is the right call.
What You Built
From scratch, using only the standard library:
- A reusable
Chainfunction for composing middleware in order - Structured request logging with response code capture
- Panic recovery with stack traces
- Token authentication with context propagation
- Configurable request timeouts
And more importantly: you now know what a framework's middleware stack actually is. It's this. Just wrapped in a nicer API.
If this was useful, the obvious next step is reading the source of Chi's middleware package. It follows exactly this pattern and is clean, readable Go. Link in the tags above.
Top comments (0)