Request logging middleware
You launch your Go web server. It starts listening on port 8080. You hit the endpoint with curl. The response comes back, but then you hit it again and get a 500 error. You stare at the terminal. The server printed nothing. You have no idea which request failed, what method it used, or where the panic originated. The server is a black box. You need a way to see every request as it flows through your application. That's what middleware does. It sits between the network and your handler, inspecting and modifying traffic without touching the business logic.
Middleware turns a black box into a glass pipe.
The handler interface
Go's HTTP server revolves around one interface: http.Handler. The interface has a single method:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Any type that implements ServeHTTP can handle requests. Middleware is just a function that takes an http.Handler, wraps it, and returns a new http.Handler. The new handler does some work, then calls the original handler.
Go provides a helper type called http.HandlerFunc to make this easier. It's an adapter that lets you use a plain function as a handler. The function signature matches ServeHTTP. The adapter implements the interface by calling your function. This avoids writing a struct and a method for every handler.
// HandlerFunc is an adapter to allow the use of ordinary
// functions as HTTP handlers.
type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
The interface is one method. The power is composition.
Minimal logging example
Here's the simplest logging middleware: wrap the handler, print the method and path, then call the next handler.
// LoggingMiddleware wraps a handler to log requests.
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 processing.
log.Printf("%s %s", r.Method, r.URL.Path)
// Pass control to the wrapped handler.
next.ServeHTTP(w, r)
})
}
When you call LoggingMiddleware(mux), you aren't executing the logging logic yet. You are creating a new handler. The function returns an http.HandlerFunc that captures next in a closure. When a request arrives, the HTTP server calls ServeHTTP on your middleware. The middleware runs the anonymous function. It prints the log line. Then it calls next.ServeHTTP. The request flows into mux, which routes it to your actual handler.
If you chain multiple middlewares, the request flows through them like a pipe. The outermost middleware runs first, then the next one, until the request reaches the innermost handler. The response flows back the other way.
// Chain middlewares by wrapping the inner handler.
// Logging runs first, then Auth, then the mux routes the request.
handler := LoggingMiddleware(AuthMiddleware(mux))
Closures capture state. The handler remembers what to call next.
Capturing status and duration
Real logging needs more than method and path. You want the status code, the duration, and the client IP. The standard http.ResponseWriter doesn't expose the status code once written. You need to wrap the writer to capture it.
The ResponseWriter interface has two main methods: Write and WriteHeader. Handlers call WriteHeader to set the status code. If they only call Write, the server implicitly calls WriteHeader(200). To capture the status, you must override WriteHeader. You also need to set a default status of 200, because some handlers never call WriteHeader explicitly.
Here's a realistic middleware that logs method, path, status, and duration.
// statusResponseWriter wraps http.ResponseWriter to capture status.
type statusResponseWriter struct {
http.ResponseWriter
status int
}
// WriteHeader captures the status code before writing.
func (w *statusResponseWriter) WriteHeader(code int) {
// Store the status for later logging.
w.status = code
// Delegate to the underlying writer.
w.ResponseWriter.WriteHeader(code)
}
// LoggingMiddleware logs method, path, status, and duration.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Track start time to measure duration.
start := time.Now()
// Wrap the writer to capture the status code.
// Default to 200 in case WriteHeader is never called.
wrapped := &statusResponseWriter{
ResponseWriter: w,
status: http.StatusOK,
}
// Call the next handler with the wrapped writer.
next.ServeHTTP(wrapped, r)
// Log details after the handler finishes.
log.Printf("%s %s %d %v", r.Method, r.URL.Path, wrapped.status, time.Since(start))
})
}
The receiver name w matches the type Writer. This is the standard Go convention for receivers. Use one or two letters that hint at the type. Don't use this or self.
The wrapper struct is unexported. The middleware function is exported. This follows the public/private convention. Implementation details stay lowercase. Public APIs start with a capital letter.
Wrap the writer to see the truth. The status code hides until you catch it.
Context and values
Middleware often needs to pass data to downstream handlers. The standard way is to use the request context. Middleware derives a new context with values and attaches it to the request. Handlers read the values from the context.
The convention is to put context.Context as the first parameter in your handler functions. Functions that take a context should respect cancellation and deadlines. Middleware can inject trace IDs, user IDs, or authentication tokens into the context.
// TraceMiddleware adds a trace ID to the request context.
func TraceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Generate a unique trace ID for this request.
traceID := uuid.New().String()
// Derive a new context with the trace ID.
ctx := context.WithValue(r.Context(), traceKey, traceID)
// Call the next handler with the modified request.
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Context is plumbing. Run it through every long-lived call site.
Pitfalls
Middleware introduces subtle bugs if you miss the details.
If you forget to call next.ServeHTTP, the request hangs. The client waits forever. The compiler won't catch this. You just get a silent timeout. If you return a handler that doesn't match the interface, the compiler rejects it with cannot use ... as http.Handler value. If you wrap the ResponseWriter but forget to implement WriteHeader, the status code won't capture correctly. The default status remains 200 even if the handler returns 404. You must override WriteHeader to intercept the code.
If you spawn a goroutine in middleware to log asynchronously, you risk a goroutine leak. The goroutine might hold a reference to the request and prevent garbage collection. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Tie the goroutine's lifecycle to the request context.
// Bad: goroutine may leak if logging blocks.
// Good: use context to cancel the goroutine.
go func() {
select {
case <-r.Context().Done():
return
case logChan <- entry:
}
}()
The worst middleware bug is the one that swallows the error and returns 200.
When to use middleware
Middleware is a powerful pattern, but it's not the only tool. Pick the right approach for the job.
Use a simple middleware wrapper when you need to log, authenticate, or modify requests for all routes. Use a custom ResponseWriter wrapper when you need to capture the status code or measure response size. Use context.WithValue inside middleware when downstream handlers need request-scoped data like user IDs or trace IDs. Use the net/http/pprof package when you need to debug performance bottlenecks rather than logging traffic. Use structured logging with slog when you need machine-readable logs for aggregation tools.
Middleware is plumbing. Keep it focused.