The plumbing problem
You are writing an HTTP handler for a user profile endpoint. The handler fetches data from a database and returns JSON. Everything works. Then you need to log every request for debugging. You add a log line at the top. Then you need authentication. You add a check for a token. Then you need rate limiting. Your handler now has fifty lines of plumbing code before it touches the actual business logic. The code is hard to read, and every new handler needs the same copy-paste boilerplate. Middleware solves this by letting you wrap handlers with reusable layers of logic.
Middleware as a wrapper
Middleware is a function that wraps an http.Handler to add behavior before or after the core handler runs. It implements the http.Handler interface itself, so you can chain multiple middlewares together. The result is a single handler that executes a sequence of steps.
Think of middleware like layers of wrapping paper around a gift. The gift is your business logic. The first layer checks the recipient's address. The second layer adds a return address label. The third layer applies a stamp. Each layer does its job and passes the package to the next layer. If any layer decides the package is invalid, it stops the chain. The recipient never gets the gift.
Middleware is cheap. The function signature is simple, and the runtime overhead is negligible. The real cost is cognitive: too many layers make it hard to trace what happens to a request. Keep the chain thin and each layer focused.
The signature and the adapter
The canonical middleware signature is func(http.Handler) http.Handler. Go's net/http package defines http.Handler as an interface with one method: ServeHTTP(http.ResponseWriter, *http.Request). Your middleware must return something that satisfies this interface.
Here's the simplest middleware: a function that measures request duration.
package main
import (
"log"
"net/http"
"time"
)
// LoggingMiddleware wraps a handler to print request duration.
func LoggingMiddleware(next http.Handler) http.Handler {
// Return a handler that implements ServeHTTP via the adapter
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Record start time to measure latency later
start := time.Now()
// Pass control to the wrapped handler
next.ServeHTTP(w, r)
// Calculate and log elapsed time after response completes
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
func main() {
// Create a simple handler for the root path
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello"))
})
// Wrap the handler with logging middleware
wrapped := LoggingMiddleware(handler)
// Start server with the wrapped handler
http.ListenAndServe(":8080", wrapped)
}
The inner function captures next in a closure. When a request arrives, the middleware's ServeHTTP runs first. It does pre-work, calls next.ServeHTTP, then does post-work. The http.HandlerFunc type is an adapter. It lets you pass a function where a handler is expected by implementing ServeHTTP on the function type. This adapter is used constantly in Go HTTP code. It bridges the gap between function values and the handler interface.
The compiler enforces the interface contract. If you return a value that doesn't implement ServeHTTP, you get cannot use ... as http.Handler value in return argument. The error is explicit. Fix the return type or add the method.
Trust the adapter. Use http.HandlerFunc whenever you need to turn a function into a handler. It is the standard way to define handlers in Go.
Chaining and order
Real applications chain multiple middlewares. Order matters. Authentication usually runs before logging or rate limiting. If auth fails, you don't want to log a successful request. You compose middlewares by nesting them. The outermost middleware runs first.
Chaining middlewares creates a pipeline where order determines execution.
func main() {
// Define the core business logic handler
apiHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Secure data"))
})
// Chain middlewares: Auth runs first, then Logging
// The result is a single handler with both behaviors
finalHandler := LoggingMiddleware(AuthMiddleware(apiHandler))
http.ListenAndServe(":8080", finalHandler)
}
The nesting looks inside-out. AuthMiddleware wraps apiHandler. LoggingMiddleware wraps the result. When a request hits finalHandler, logging runs, then auth runs, then the API handler runs. On the way back, auth post-work runs, then logging post-work runs.
Composition over inheritance. Wrap handlers, don't subclass them. Go has no class inheritance, and middleware chaining is the idiomatic way to share behavior.
Capturing response details
The http.ResponseWriter interface has Write and WriteHeader. It does not expose the status code after writing. If you want to log the status code, you must wrap the writer. This is a common pattern in middleware that needs metrics or detailed logging.
Here's a status recorder that captures the code.
// statusRecorder wraps ResponseWriter to capture the status code.
type statusRecorder struct {
http.ResponseWriter
statusCode int
}
// WriteHeader captures the code before delegating to the underlying writer.
func (rec *statusRecorder) WriteHeader(code int) {
rec.statusCode = code
rec.ResponseWriter.WriteHeader(code)
}
// LoggingMiddlewareWithStatus wraps a handler to log method, path, and status.
func LoggingMiddlewareWithStatus(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Create a recorder to intercept the status code
rec := &statusRecorder{ResponseWriter: w, statusCode: http.StatusOK}
// Pass the recorder to the next handler
next.ServeHTTP(rec, r)
// Log the captured status code after the response completes
log.Printf("%s %s %d", r.Method, r.URL.Path, rec.statusCode)
})
}
The receiver name is rec, a short abbreviation matching the type. Go convention prefers one or two letter receiver names. Never use this or self. The compiler doesn't care, but the community does.
The recorder embeds http.ResponseWriter. This promotes all methods automatically. You only override WriteHeader to capture the code. If the handler never calls WriteHeader, the default status is 200. The recorder initializes statusCode to 200 to handle that case.
Don't fight the type system. Wrap the value or change the design. If you need more response details, extend the recorder. If you need to modify the body, wrap Write and buffer the output.
Protecting the server
Panics in handlers crash the goroutine serving the request. The server stays up, but the client gets a broken connection. Recovery middleware catches panics and returns a 500 error. It keeps the server stable and logs the failure.
Here's a recovery middleware that catches panics.
// RecoveryMiddleware wraps a handler to catch panics and return 500.
func RecoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Defer recovery to catch panics from the next handler
defer func() {
if err := recover(); err != nil {
// Log the panic details for debugging
log.Printf("panic: %v", err)
// Return 500 to the client
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
// Execute the next handler
next.ServeHTTP(w, r)
})
}
The defer runs after next.ServeHTTP returns or panics. If a panic occurs, recover captures the value. The middleware logs it and writes a 500 response. The client gets a clean error instead of a connection reset.
Recovery middleware should be the outermost layer. It protects everything inside. If you put it after auth, a panic in auth still crashes the goroutine.
The worst middleware bug is the one that swallows the error and returns 200. Always log failures. Always return appropriate status codes.
Context and conventions
Middleware is the gatekeeper for context. It injects values like trace IDs, user IDs, or deadlines. Downstream handlers read from context, not headers. This keeps handlers pure and testable.
Context is plumbing. Run it through every long-lived call site.
When setting context values, use unexported key types to avoid collisions. Multiple packages might use string keys, and a collision causes bugs that are hard to trace.
// userIDKey is an unexported type to prevent key collisions.
type userIDKey struct{}
// AuthMiddleware checks for a valid token and sets user ID in context.
func AuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Extract token from header for validation
token := r.Header.Get("Authorization")
if token == "" {
// Reject request immediately if token is missing
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Add validated user info to context for downstream handlers
ctx := context.WithValue(r.Context(), userIDKey{}, "123")
// Pass new request with updated context to next handler
next.ServeHTTP(w, r.WithContext(ctx))
})
}
The key userIDKey{} is a struct type. Only this package can create values of this type. Other packages cannot accidentally use the same key. This is the standard pattern for context keys in Go.
context.Context always goes as the first parameter in functions that need it. Conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If the context is done, return early.
Middleware should also respect context cancellation. If the client disconnects, r.Context().Done() signals. Long-running middleware should check this channel to avoid wasting resources.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If middleware spawns a goroutine, pass the request context. When the context cancels, the goroutine should exit.
Pitfalls and errors
The most common bug is forgetting to call next.ServeHTTP. If the middleware returns without calling the next handler, the client receives a timeout. The server goroutine finishes, but no response is written. The browser waits until it gives up. The compiler won't catch this. You have to read the code.
If you call next.ServeHTTP twice, the second call tries to write headers again. The runtime panics with http: multiple Response.WriteHeader calls. This crashes the handler goroutine. The server stays up, but the request fails.
Modifying the request directly is dangerous. r.Context() is immutable, but the request object is mutable. Always use r.WithContext(newCtx) when passing to next. This returns a shallow copy of the request with the new context. Don't mutate r.Header directly unless you know what you are doing, as the server might reuse buffers.
If you wrap the response writer, ensure you call the underlying methods. If you forget to call rec.ResponseWriter.WriteHeader, the client never gets the status code. The response hangs.
The compiler complains with cannot use x as http.Handler value in return argument if you return the wrong type. Fix the return value. If you forget to import a package, you get undefined: pkg. If you import and don't use it, you get imported and not used. Go is strict about imports. Remove unused imports immediately.
gofmt is mandatory. Don't argue about indentation. Let the tool decide. Most editors run it on save. Your middleware code should look identical to everyone else's.
When to use middleware
Use middleware when you need cross-cutting concerns like logging, auth, or compression that apply to many handlers.
Use a base handler struct when you need to share state or methods across handlers without global variables.
Use inline code when the logic is unique to a single endpoint and won't be reused.
Use a router library with built-in middleware support when you prefer a fluent API for grouping routes and attaching middleware to sub-trees.
Use plain sequential code when you don't need the flexibility of chaining: the simplest thing that works is usually the right thing.
Accept interfaces, return structs. Middleware returns http.Handler, an interface. The implementation is a struct or function. This is the most common Go style mantra. It keeps dependencies loose and testing easy.