Right, so you’ve got a handler. It does a thing. It’s a beautiful, pure function that takes a ResponseWriter and a *Request and just… handles. But now you want it to also log every request. And maybe check for an authentication header. And compress the response. And add security headers.

Your first, most horrifying instinct might be to just go into your perfect little handler function and start adding a bunch of log.Println() statements and if blocks. Don’t. You’ll turn it into a tangled mess of orthogonal concerns, and I will personally come to your house and refactor your code while muttering angrily under my breath.

The sane person’s solution is middleware. The concept is brilliantly simple: since a Handler is just an interface with one method, any function that takes a http.Handler and returns a http.Handler can wrap it. You’re putting a layer around the original handler. That layer can do stuff before it calls the inner handler, and it can do stuff after. It’s like an onion, but for HTTP, and significantly less likely to make you cry.

Here’s the most basic, canonical example: a logging middleware.

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Do stuff BEFORE the inner handler runs
        start := time.Now()
        log.Printf("Started %s %s", r.Method, r.URL.Path)

        // This call is the magic. This is where the next handler in the chain is called.
        next.ServeHTTP(w, r)

        // Do stuff AFTER the inner handler has run.
        log.Printf("Completed %s in %v", r.URL.Path, time.Since(start))
    })
}

You use it by wrapping your original handler. If you’re using a ServeMux, it looks like this:

mux := http.NewServeMux()
mux.Handle("/path", loggingMiddleware(myHandler))

The flow is crystal clear: the request comes in, hits the logging middleware, which notes the start time, logs the start, and then calls next.ServeHTTP(...). That next handler is your actual myHandler. It does its business, returns, and then control flows back to the middleware, which logs the completion time. Elegant.

The Critical Importance of the next.ServeHTTP Call

This is the most common rookie mistake: forgetting to call next.ServeHTTP(w, r). If you do this, your middleware becomes a black hole. The request comes in, your middleware runs its pre-logic, and then… nothing. It never calls the next handler. The request is never actually handled. It just dies in your middleware function, and the client gets an empty response after a timeout. Always, always, always call next unless you are explicitly intending to terminate the request chain (like in an auth middleware that rejects an invalid user).

The Two-Pattern Problem: Functional and Struct-Based

You’ll see two main styles in the wild. The first, which I showed above, is the functional pattern. It’s clean and perfect for simple middleware that doesn’t need its own state.

The second is the struct-based pattern. You need this when your middleware requires configuration or must manage its own state. An authentication middleware is a classic case.

// authMiddleware holds state (a JWT signing key).
type authMiddleware struct {
    next    http.Handler
    jwtKey  []byte
}

func (a *authMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Extract and validate the token from the request using a.jwtKey
    if !a.validateToken(r) {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return // Notice we DON'T call a.next.ServeHTTP. We break the chain.
    }
    // If valid, call the next handler.
    a.next.ServeHTTP(w, r)
}

// This constructor function makes it nice to use.
func newAuthMiddleware(jwtKey []byte) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return &authMiddleware{next: next, jwtKey: jwtKey}
    }
}

You use the struct-based version the same way, but now you can pass it configuration:

mux.Handle("/admin", newAuthMiddleware(myKey)(adminHandler))

The Order of Operations is Everything

Middleware wraps from the inside out. The order you apply them in is the order they will execute on the way in, and then in reverse order on the way out. Think of it like a stack of nested function calls.

// Applied like this:
handler := loggingMiddleware(authMiddleware(compressionMiddleware(realHandler)))

// Executes in this order:
// 1. loggingMiddleware pre-logic
// 2. authMiddleware checks token
// 3. compressionMiddleware wraps the ResponseWriter
// 4. realHandler runs
// 5. compressionMiddleware compresses the response body
// 6. authMiddleware does nothing on the way out
// 7. loggingMiddleware posts the completion log

This is why you almost always want to put logging on the very outside. It should log the entire duration of the request, including the time spent on auth and compression. And you want auth before your application handler, but it often needs to be after any middleware that might read the request body, lest you break the request parsing for the auth logic. It’s a puzzle. A fun one.

Wrapping the ResponseWriter: The Final Boss

Here’s where things get spicy. What if your middleware needs to know the result of the inner handler? For example, a logging middleware wants to log the status code. The problem is the inner handler calls w.WriteHeader(404). How does the outer logging middleware see that?

The answer is you have to wrap the http.ResponseWriter. You create a new type that embeds the original ResponseWriter and overrides its methods to capture the status code.

type loggingResponseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (lrw *loggingResponseWriter) WriteHeader(code int) {
    lrw.statusCode = code // Capture the status code
    lrw.ResponseWriter.WriteHeader(code)
}

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap the original ResponseWriter with our custom one
        myWriter := &loggingResponseWriter{
            ResponseWriter: w,
            statusCode:     http.StatusOK, // Default to 200 if WriteHeader isn't called
        }

        next.ServeHTTP(myWriter, r) // Call the chain with the wrapped writer

        // Now we can access the recorded status code!
        log.Printf("%s %s - %d (%v)", r.Method, r.URL.Path, myWriter.statusCode, time.Since(start))
    })
}

This pattern is the key to unlocking powerful middleware. Compression middleware uses it to buffer the output and compress it. Metrics middleware uses it to record status codes and response sizes. It’s the superpower of the Go HTTP stack. Just remember: the http.ResponseWriter interface is surprisingly small (Header(), Write([]byte), WriteHeader(int)), which makes it relatively straightforward to wrap correctly.