How to Write Reusable Middleware for net/http

Web
Write a function that accepts an `http.Handler` and returns a new `http.Handler` wrapping it to execute logic before or after the request.

The maintenance trap of repeated logic

You are building a web service. You add a /users endpoint. It works. You add /orders. It works. Then your manager asks for request logging on every route. You paste the logging code into /users. You paste it into /orders. Two weeks later, you need authentication on protected routes. You paste the auth check into five more handlers. Change the log format tomorrow and you edit twelve files. Change the auth provider next month and you edit twenty. The repetition creates a maintenance trap. You need a mechanism to attach behavior to handlers without touching the handler code itself. Middleware solves this by wrapping handlers in layers of functionality.

The wrapping pattern and the adapter

Think of middleware as an assembly line around a core product. The product is your handler that builds the response. The assembly line adds a label, scans a barcode, or wraps it in protective foam. The customer receives the finished package, but the core product remains untouched. In Go, net/http uses the http.Handler interface. Middleware is just a function that takes a handler and returns a new handler that does extra work before or after calling the original.

The http.Handler interface requires a single method: ServeHTTP(w http.ResponseWriter, r *http.Request). Functions do not have methods, so the standard library provides http.HandlerFunc as an adapter. It wraps a plain function so it satisfies the interface. When you return http.HandlerFunc(...), you are creating a value that implements http.Handler. The closure captures the next variable. When the server receives a request, it calls ServeHTTP on the outer handler. That handler runs the pre-logic, calls next.ServeHTTP, then runs the post-logic. The request flows through the layers like water through a filter.

Go code follows strict formatting rules. Run gofmt on your middleware files. The tool decides indentation and spacing. Don't argue with it. Most editors run it on save.

Minimal middleware

Here is the canonical middleware signature: a function that accepts an http.Handler and returns a new http.Handler.

package main

import (
	"fmt"
	"net/http"
)

// LoggingMiddleware wraps a handler to print request paths.
func LoggingMiddleware(next http.Handler) http.Handler {
	// Return an adapter that satisfies http.Handler.
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Run pre-logic before the downstream handler executes.
		fmt.Println("Before:", r.URL.Path)
		// Delegate to the next handler in the chain.
		next.ServeHTTP(w, r)
		// Run post-logic after the downstream handler returns.
		fmt.Println("After:", r.URL.Path)
	})
}

func main() {
	// Base handler produces the actual response.
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello"))
	})

	// Wrap the base handler with the logging layer.
	wrapped := LoggingMiddleware(handler)

	// Start the server with the composed handler.
	http.ListenAndServe(":8080", wrapped)
}

How the chain executes at runtime

The adapter pattern bridges the gap between plain functions and the http.Handler interface. http.HandlerFunc is a type defined as type HandlerFunc func(ResponseWriter, *Request). It implements ServeHTTP by calling itself. When you pass a function literal to http.HandlerFunc, you are boxing that function into a type that the server understands. The closure captures next by reference, so the inner function retains access to the downstream handler even after LoggingMiddleware returns.

When a request arrives, the server calls ServeHTTP on the outermost wrapper. That wrapper executes its pre-logic, then calls next.ServeHTTP. Control passes to the next layer. Each layer repeats the pattern until the final handler runs. The response then bubbles back up through each layer's post-logic. The stack unwinds in reverse order. This design keeps handlers focused on business logic while middleware handles cross-cutting concerns.

Middleware is a composition tool, not a framework. Keep each layer focused on one responsibility.

Passing data downstream with context

Middleware often needs to share information with the handler. The standard way to pass data downstream is through the request context. Contexts are designed to carry request-scoped values, deadlines, and cancellation signals across API boundaries. When middleware adds data, use context.WithValue and pass the new request via r.WithContext(ctx). Downstream handlers read from r.Context().

Always pass context.Context as the first parameter in your own functions. This is a hard convention in Go. Functions that take a context should respect cancellation and deadlines. Use a custom type for context keys to prevent collisions with other packages. String keys work, but they cause silent collisions when two packages use the same string. A custom type guarantees uniqueness.

Here is a middleware that injects a request ID.

package main

import (
	"context"
	"net/http"
)

// requestIDKey is a custom type to prevent context key collisions.
type requestIDKey struct{}

// RequestIDMiddleware injects a unique identifier into the request context.
func RequestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Attach the ID to a new context derived from the request.
		ctx := context.WithValue(r.Context(), requestIDKey{}, "req-123")
		// Replace the request's context and pass it downstream.
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Here is a middleware that measures execution time.

package main

import (
	"fmt"
	"net/http"
	"time"
)

// TimingMiddleware measures execution time of the wrapped handler.
func TimingMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Record the start time before delegating.
		start := time.Now()
		// Run the downstream logic.
		next.ServeHTTP(w, r)
		// Log the elapsed time after the response is sent.
		fmt.Printf("Duration: %v\n", time.Since(start))
	})
}

Compose them by nesting the calls. Wrapping TimingMiddleware(RequestIDMiddleware(handler)) means timing runs first, then request ID injection, then the base handler. The response flows back in reverse order. Context is plumbing. Run it through every long-lived call site.

Short-circuiting and error handling

Middleware order defines the execution flow. If you wrap timing around authentication, the timer measures the auth check too. If you wrap authentication around timing, the timer only measures the handler when auth succeeds. Choose the order based on what you want to measure or protect.

Middleware can also short-circuit the chain. If authentication fails, don't call next. Write a 401 status and return. This stops the request from reaching the handler. The compiler does not enforce this. It is a design choice. If you return early, ensure you write a response. Otherwise, the client hangs waiting for data that never arrives.

package main

import (
	"net/http"
)

// AuthMiddleware checks for a valid token and short-circuits on failure.
func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		token := r.Header.Get("Authorization")
		// Reject the request immediately if the token is missing.
		if token == "" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		// Continue the chain only when the token is present.
		next.ServeHTTP(w, r)
	})
}

The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Apply the same discipline to middleware. Check for context cancellation before delegating. Validate headers before trusting them. Write explicit error responses instead of panicking. Panics unwind the entire goroutine and crash the request. Use structured error returns or http.Error to keep the server stable.

Testing middleware in isolation

Middleware is pure composition, which makes it easy to test. You do not need a running server. Use httptest.NewRecorder to capture the response and httptest.NewRequest to build the request. Wrap your middleware around a dummy handler that records whether it was called. This lets you verify short-circuiting, header modification, and context injection without network overhead.

package main

import (
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestAuthMiddleware(t *testing.T) {
	// Create a recorder to capture the response.
	rec := httptest.NewRecorder()
	// Build a request without the required header.
	req := httptest.NewRequest(http.MethodGet, "/protected", nil)

	// Wrap the middleware around a handler that tracks execution.
	called := false
	handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		called = true
	})
	wrapped := AuthMiddleware(handler)

	// Execute the middleware chain.
	wrapped.ServeHTTP(rec, req)

	// Verify the response status and that the handler was skipped.
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("expected 401, got %d", rec.Code)
	}
	if called {
		t.Fatal("handler should not be called on missing token")
	}
}

Test each layer independently. Verify that pre-logic runs, post-logic runs, and short-circuiting stops the chain. Mock external dependencies like databases or auth providers. Keep tests fast and deterministic.

Common pitfalls and compiler feedback

If you forget to call next.ServeHTTP, the request hangs. The client waits forever. The server logs nothing. The middleware swallows the request. Always call the next handler unless you intentionally short-circuit.

If you modify the http.ResponseWriter after the handler writes, you might corrupt the response. The http.ResponseWriter interface is simple but unforgiving. If you try to set headers after the body starts writing, the compiler will not stop you. The runtime will ignore the headers. You get http: superfluous response.WriteHeader call if you call WriteHeader after Write. Headers must be set before the first byte of the body reaches the network.

If you return a function directly instead of wrapping it in http.HandlerFunc, the compiler rejects the code with cannot use func literal (value of type func(http.ResponseWriter, *http.Request)) as http.Handler value in return argument. The function type does not implement the interface. You must use the adapter.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Middleware rarely spawns goroutines, but if you do, tie their lifecycle to r.Context(). When the client disconnects, the context cancels and your background work stops.

The worst goroutine bug is the one that never logs.

When to use middleware

Use middleware when the logic applies to multiple handlers, such as logging, authentication, or request ID injection. Use middleware when you need to inspect or modify the request before it reaches the handler, or the response after the handler completes. Use plain handler logic when the behavior is specific to a single endpoint and does not repeat elsewhere. Use a router library when you need path parameters, method matching, or group-based middleware registration that the standard library does not provide. Use http.HandlerFunc directly when you are writing a quick script and do not need the composition pattern.

Middleware is a composition tool, not a framework. Keep each layer focused on one responsibility. Chain them deliberately. Test them in isolation.

Where to go next