The problem with doing everything in one handler
You write a handler that fetches a user from a database. It works. Then you need to log every request. You add a print statement. Then you need to check authentication. You add an if block. Then you need to time how long the query takes. You wrap the whole thing in another function. The handler is now a tangled mess of cross-cutting concerns. Go solves this by treating middleware as a simple wrapping function. You do not modify the handler. You build a chain around it.
What middleware actually is
Middleware is a function that sits between the incoming request and your business logic. Think of it like a security checkpoint at an airport. The passenger is the HTTP request. The flight is your main handler. The checkpoint does not fly the plane. It checks tickets, scans bags, and stamps passports. If everything passes, the passenger moves to the next gate. If something fails, the process stops right there. In Go, every piece of middleware receives the next handler in the chain and decides whether to call it.
The standard library exposes this pattern through the http.Handler interface. It has a single method: ServeHTTP(http.ResponseWriter, *http.Request). Any type that implements that method can process a request. Middleware does not implement ServeHTTP directly. It accepts an http.Handler, wraps it in a closure, and returns a new http.Handler. The returned handler runs your middleware code, then optionally delegates to the wrapped handler.
Middleware is plumbing. Keep it focused on one responsibility per layer.
The minimal pattern
Go provides a built-in adapter called http.HandlerFunc. It lets you write a plain function and automatically satisfies the http.Handler interface. Middleware uses this adapter to return a valid handler without defining a custom struct.
// LoggingMiddleware prints the method and path before passing control forward.
func LoggingMiddleware(next http.Handler) http.Handler {
// Return a new handler that wraps the provided next handler.
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Record the request details before touching the downstream logic.
log.Printf("%s %s", r.Method, r.URL.Path)
// Hand the request to the next step in the chain.
next.ServeHTTP(w, r)
})
}
The function signature follows a strict convention. The parameter is named next and typed as http.Handler. The return type is also http.Handler. This symmetry makes chaining readable. The closure captures next and runs it after your custom code. If you forget to call next.ServeHTTP, the request never reaches your actual handler. The client hangs until it times out.
Go favors composition over inheritance. Middleware is just function composition applied to HTTP.
How the chain executes
When you wrap multiple middlewares, you create an onion. The outermost layer runs first. It executes its pre-handling code, calls the next layer, and then executes its post-handling code after the inner layers return. This execution order is deterministic and stack-based.
// TimingMiddleware measures how long the downstream handler takes to respond.
func TimingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture the start time so we can calculate duration later.
start := time.Now()
// Execute the next handler in the chain.
next.ServeHTTP(w, r)
// Log the elapsed time after the downstream logic finishes.
log.Printf("took %s", time.Since(start))
})
}
Chain them by passing the inner handler into the outer wrapper. The order matters. TimingMiddleware(LoggingMiddleware(mux)) logs first, then times. The timing layer wraps the logging layer, so the clock starts before the log prints and stops after the inner handler returns.
The standard library does not provide a built-in chain builder. You compose them manually or use a third-party router that handles the wiring. Manual composition keeps your dependency footprint small and makes the execution order explicit.
Read the chain from inside out. The innermost handler is your business logic. Everything outside it is infrastructure.
A realistic logging and timing setup
Real middleware rarely just prints to stdout. It extracts data from the request context, measures performance, and handles errors. Go conventions dictate that context.Context always travels as the first parameter in function signatures, conventionally named ctx. Middleware often creates a new context to attach request-scoped values like trace IDs or deadlines.
// TraceMiddleware attaches a unique request ID to the context for downstream use.
func TraceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate a unique identifier for this specific request lifecycle.
traceID := uuid.New().String()
// Store the ID in a new context derived from the request.
ctx := context.WithValue(r.Context(), "traceID", traceID)
// Replace the request's context so downstream handlers see the new value.
r = r.WithContext(ctx)
// Pass the modified request to the next handler in the chain.
next.ServeHTTP(w, r)
})
}
The context.WithValue call creates a new context without mutating the original. Go contexts are immutable by design. You never modify a context in place. You derive a new one and pass it forward. The r.WithContext method returns a shallow copy of the request with the updated context field. Downstream handlers read the value with ctx.Value("traceID").
Accept interfaces, return structs. Middleware accepts http.Handler and returns http.Handler. It never returns a concrete struct or a function pointer. This rule keeps your code flexible and testable.
Context is plumbing. Run it through every long-lived call site.
Where things go wrong
Middleware introduces subtle failure modes. The most common is forgetting to call next.ServeHTTP. The compiler will not catch this omission. The program compiles cleanly. The server starts. The client sends a request. The middleware runs its pre-handling code, then returns. The response never gets written. The client waits until the timeout fires. Add a defensive check or a linter rule if you work in a large team.
Another frequent issue is writing to the http.ResponseWriter after headers have already been sent. If your middleware calls w.WriteHeader(http.StatusOK) and then the inner handler also calls WriteHeader, the standard library panics with http: superfluous response.WriteHeader call. Middleware should only write headers if it is short-circuiting the chain. If you pass control to next, let the inner handler manage the status code.
Type mismatches also trip up newcomers. If you try to pass a raw function to http.ListenAndServe without wrapping it in http.HandlerFunc, the compiler rejects the program with cannot use func(http.ResponseWriter, *http.Request) { ... } (value of type func(http.ResponseWriter, *http.Request)) as http.Handler value in argument. The adapter exists for a reason. Use it.
Goroutine leaks happen when middleware spawns a background goroutine that waits on a channel tied to the request lifecycle. If the client disconnects, the channel never closes. The goroutine hangs forever. Always pass a cancellable context to background work and defer the cancellation call.
The worst goroutine bug is the one that never logs.
When to reach for middleware
Use middleware when you need cross-cutting behavior like logging, authentication, or request timing. Use a custom http.Handler struct when your logic requires state that outlives a single request. Use a router package like chi or gorilla/mux when you need path grouping and subrouters. Use plain handler functions when the request only needs to read data and return a response without side effects.
Middleware is cheap. Channels are not magic.