How to Use Middleware in Go HTTP Servers

Web
Implement Go HTTP middleware by wrapping http.Handler functions to execute logic before or after the main request handler.

How to Use Middleware in Go HTTP Servers

You're building an API. Every endpoint needs to check for a valid session token. You also want to log the request method and path. And maybe measure how long each request takes. Copy-pasting that logic into every handler turns your code into a mess of duplicated boilerplate. You need a way to run shared logic before or after your actual route handlers, without touching the handlers themselves. That's middleware.

Go doesn't have a built-in middleware framework. Instead, middleware is a pattern built on top of the standard library's http.Handler interface. You implement it by wrapping handlers in a chain. Each wrapper runs its logic, then decides whether to pass control to the next handler or send a response immediately.

Think of middleware like a bouncer at a club. The bouncer checks IDs before letting anyone in. If the ID is fake, the bouncer stops the person right there. If the ID is good, the bouncer passes them through to the DJ booth. The DJ doesn't care about IDs; the DJ just plays music. In Go, the bouncer is your middleware function. The DJ is your route handler. The middleware wraps the handler, runs its check, and then decides whether to hand control over to the handler or send a response immediately.

The minimal middleware pattern

Here's the skeleton of a middleware function. It takes an http.Handler, returns a new http.Handler, and runs code around the original handler.

// LoggingMiddleware wraps a handler to print request details.
func LoggingMiddleware(next http.Handler) http.Handler {
    // Return a new handler that implements the ServeHTTP method.
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Log the method and path before passing control downstream.
        log.Printf("Request: %s %s", r.Method, r.URL.Path)
        
        // Call the next handler in the chain.
        // This is where the actual route logic runs.
        next.ServeHTTP(w, r)
    })
}

Go's HTTP package defines an interface called http.Handler with one method: ServeHTTP(w http.ResponseWriter, r *http.Request). Any type that implements this method can handle HTTP requests. Functions don't implement interfaces directly in Go, so the standard library provides http.HandlerFunc as an adapter. It lets you treat a function as a handler. When you write http.HandlerFunc(func...), you're creating a value that satisfies the interface.

The middleware returns this wrapped function. When the server receives a request, it calls ServeHTTP on your middleware. Your middleware runs its logic, then calls next.ServeHTTP, which invokes the wrapped handler. This creates a chain. You can wrap handlers inside other handlers to build a pipeline.

Middleware is a chain. Break the chain and the request dies.

Chaining and order

Real applications chain multiple middleware functions. You might want logging, then authentication, then your routes. The order matters: the first middleware runs first. If you wrap Auth inside Logging, the logging runs before the auth check. If you wrap Logging inside Auth, the auth check runs first.

Here's how to wire them together using http.NewServeMux. The mux is just another handler, so you can wrap it too.

// AuthMiddleware checks for a valid token in the header.
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Extract the token from the Authorization header.
        token := r.Header.Get("Authorization")
        
        // Reject the request early if the token is missing.
        if token == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }
        
        // Token exists; proceed to the next handler.
        next.ServeHTTP(w, r)
    })
}
func main() {
    // Create the base router with route handlers.
    mux := http.NewServeMux()
    mux.HandleFunc("/public", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Public content"))
    })
    mux.HandleFunc("/secret", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("Secret content"))
    })

    // Wrap the mux with middleware.
    // Auth runs first, then Logging, then the Mux dispatches.
    handler := LoggingMiddleware(AuthMiddleware(mux))
    
    // Start the server with the fully wrapped handler.
    log.Fatal(http.ListenAndServe(":8080", handler))
}

Notice the nesting. AuthMiddleware(mux) returns a handler. LoggingMiddleware wraps that result. When a request comes in, LoggingMiddleware executes, calls next, which triggers AuthMiddleware, which calls next, which triggers the mux. The execution flows from the outermost wrapper to the innermost handler.

Order is execution order. First wrapped runs first.

Testing middleware in isolation

Middleware is easy to test because it's just a function. You don't need to start a server. Use httptest to create a fake request and response recorder. This lets you verify behavior without network overhead.

func TestLoggingMiddleware(t *testing.T) {
    // Create a mock handler that writes a response.
    mockHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    })
    
    // Wrap the mock handler with the middleware.
    handler := LoggingMiddleware(mockHandler)
    
    // Create a test request and response recorder.
    req := httptest.NewRequest(http.MethodGet, "/test", nil)
    rec := httptest.NewRecorder()
    
    // Call ServeHTTP directly.
    handler.ServeHTTP(rec, req)
    
    // Assert the response status.
    if rec.Code != http.StatusOK {
        t.Errorf("Expected status 200, got %d", rec.Code)
    }
}

The httptest.NewRecorder captures everything written to the ResponseWriter. After calling ServeHTTP, you can inspect rec.Code, rec.Body, and headers. This pattern works for any middleware. Test the happy path, test the short-circuit path, and test edge cases.

Test middleware in isolation. No server needed.

Capturing status codes

One limitation of http.ResponseWriter is that it doesn't expose the status code after WriteHeader is called. If your logging middleware needs to record the status code, you have to wrap the writer in a custom type that intercepts the call. The interface hides the status on purpose to keep the contract simple, so you must provide the storage yourself.

// statusRecorder wraps http.ResponseWriter to capture the status code.
type statusRecorder struct {
    http.ResponseWriter
    statusCode int
}

// WriteHeader intercepts the call to store the status.
func (rec *statusRecorder) WriteHeader(code int) {
    rec.statusCode = code
    rec.ResponseWriter.WriteHeader(code)
}

You can use this wrapper inside middleware. Create the recorder, pass it to next.ServeHTTP, and read rec.statusCode afterward. Remember that the default status is 200. If WriteHeader is never called, the status remains 200, so initialize your recorder with statusCode: 200.

Wrap the writer if you need the status. The interface hides it on purpose.

Enriching the request context

Middleware is the standard place to attach context values. If you extract a user ID from a token, store it in the context so downstream handlers can access it without re-parsing. Use context.WithValue with a custom key type to avoid collisions. Never use string keys for context values; that invites namespace clashes across packages.

// UserIDKey is a custom type for the context key.
type UserIDKey struct{}

// ContextMiddleware extracts the user ID and adds it to the context.
func ContextMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Extract user ID from token (simplified logic).
        userID := extractUserID(r.Header.Get("Authorization"))
        
        // Clone the request with a new context containing the user ID.
        ctx := context.WithValue(r.Context(), UserIDKey{}, userID)
        newReq := r.WithContext(ctx)
        
        // Pass the enriched request downstream.
        next.ServeHTTP(w, newReq)
    })
}

Downstream handlers can retrieve the value using r.Context().Value(UserIDKey{}). The convention is to use a struct type for the key, even if it's empty. This guarantees uniqueness. Also, r.WithContext returns a shallow copy of the request. This is safe because the request object is not shared across goroutines in the standard server model.

Context is plumbing. Run it through every long-lived call site.

Pitfalls and errors

Middleware bugs usually fall into two categories: breaking the chain or writing to the response at the wrong time. If you forget to call next.ServeHTTP, the request hangs. The client waits forever because no response is sent. The server doesn't crash; it just leaks a goroutine waiting for the client to timeout. The worst goroutine bug is the one that never logs.

Another trap is writing headers or the body before calling the next handler. If your middleware writes a response and then calls next, the downstream handler might try to write again. The compiler won't catch this. At runtime, you'll see http: superfluous response.WriteHeader call in your logs, or the client will receive a garbled response with duplicate headers. Always write to http.ResponseWriter only once. If the middleware decides to short-circuit, return immediately without calling next.

Be careful with panics in middleware. If a handler panics, the panic propagates up through the middleware chain. If you don't recover it, the server logs a stack trace and closes the connection. You can add a recovery middleware at the top of the chain to catch panics and return a 500 error. This keeps your server stable even when handlers misbehave.

When to use middleware

Use middleware when you have cross-cutting concerns like logging, authentication, or tracing that apply to multiple routes. Use a wrapper function inside a handler when the logic is specific to a single endpoint. Use http.Handler composition when you want to build reusable components that can be mixed and matched. Use a third-party router when you need path parameters and group-level middleware registration, though the standard library suffices for simple apps. Use plain sequential code when you don't need middleware: wrapping adds indirection that can make debugging harder if overused.

Don't over-wrap. Simple handlers are easier to test.

Where to go next