HTTP Middleware
Reviewed & published by Brayan K
Middleware is a function that takes an http.Handler and returns a new one wrapping it, letting you run logging, authentication, panic recovery, and other cross-cutting logic around every request without touching your handlers.
Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Write middleware as func(http.Handler) http.Handler
- Run logic before and after the inner handler
- Short-circuit a request (auth, validation)
- Chain several middleware in the right order
- Wrap ResponseWriter to capture the status code
- Recover from panics into a clean 500
1️⃣ The middleware shape
Middleware has one shape: func(next http.Handler) http.Handler. It returns a new handler (usually an http.HandlerFunc) that does something, then calls next.ServeHTTP(w, r) to invoke the wrapped handler — and may do more afterward. Because input and output are both http.Handler, middleware composes freely.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// Middleware is a function that takes a handler and returns a NEW handler
// that wraps it — doing something before and/or after the inner handler.
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("--> %s %s\n", r.Method, r.URL.Path) // before
next.ServeHTTP(w, r) // call the wrapped handler
fmt.Printf("<-- done %s\n", r.URL.Path) // after
})
}
func main() {
// The actual endpoint.
hello := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hello")
})
// Wrap it. Now every request is logged around the handler.
wrapped := logging(hello)
req := httptest.NewRequest(http.MethodGet, "/hi", nil)
rec := httptest.NewRecorder()
wrapped.ServeHTTP(rec, req)
fmt.Println("body:", rec.Body.String())
}2️⃣ Chaining and short-circuiting
Stack middleware by wrapping: logging(auth(handler)) runs logging outermost. A small chain helper lets you list them outermost-first. A middleware short-circuits by writing a response and returning without calling next — that's how auth blocks a request with no token.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Printf("log: %s %s\n", r.Method, r.URL.Path)
next.ServeHTTP(w, r)
})
}
// auth checks a header and SHORT-CIRCUITS with 401 if it's missing —
// the inner handler never runs.
func auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Token") != "secret" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return // stop the chain here
}
next.ServeHTTP(w, r)
})
}
// chain applies middleware so the FIRST listed runs OUTERMOST.
func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler {
for i := len(mws) - 1; i >= 0; i-- {
h = mws[i](h)
}
return h
}
func main() {
secret := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "secret data")
})
handler := chain(secret, logging, auth) // logging(auth(secret))
// With a valid token.
req := httptest.NewRequest(http.MethodGet, "/data", nil)
req.Header.Set("X-Token", "secret")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
fmt.Printf("ok: %d %s\n", rec.Code, rec.Body.String())
// Without a token -> blocked by auth.
req2 := httptest.NewRequest(http.MethodGet, "/data", nil)
rec2 := httptest.NewRecorder()
handler.ServeHTTP(rec2, req2)
fmt.Printf("deny: %d %s", rec2.Code, rec2.Body.String())
}3️⃣ Wrapping ResponseWriter & recovering from panics
To log the status code, you must capture it — the plain ResponseWriter won't tell you. Embed it in a struct and override WriteHeader to remember the code (defaulting to 200). And recovery middleware uses defer + recover() to turn a handler panic into a clean 500 instead of an empty response.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// statusRecorder wraps http.ResponseWriter to remember the status code the
// handler chose, so middleware can log it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
func loggingWithStatus(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rec := &statusRecorder{ResponseWriter: w, status: 200} // default 200
next.ServeHTTP(rec, r)
fmt.Printf("%s %s -> %d\n", r.Method, r.URL.Path, rec.status)
})
}
// recovery turns a panic in any handler into a clean 500 instead of crashing
// the whole server.
func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
fmt.Println("recovered from panic:", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
boom := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
panic("something broke")
})
handler := loggingWithStatus(recovery(boom))
req := httptest.NewRequest(http.MethodGet, "/boom", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
fmt.Printf("response: %d %s", rec.Code, rec.Body.String())
}🎯 Your Turn
Write a tiny middleware that adds a response header. Fill in the two blanks marked ___, then run it.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// 🎯 YOUR TURN — fill in the blanks marked ___
// addHeader is middleware that sets a custom response header, then calls
// the next handler.
func addHeader(next http.Handler) http.Handler {
// 1) Return a handler. http.HandlerFunc adapts a func to http.Handler.
return ___(func(w http.ResponseWriter, r *http.Request) { // 👉 http.HandlerFunc
w.Header().Set("X-Powered-By", "Go")
// 2) Call the wrapped handler
next.___(w, r) // 👉 ServeHTTP
})
}
func main() {
hello := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "hi")
})
h := addHeader(hello)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(rec.Header().Get("X-Powered-By"), rec.Body.String())
// ✅ Expected output:
// Go hi
}Common Errors (and the fix)
❌ Forgetting next.ServeHTTP(w, r) — the chain dead-ends; the handler never runs.
✅ Call next exactly once in any pass-through middleware.
❌ Wrong wrap order — e.g. logging the status before recovery can catch a panic.
✅ Recovery outermost, then logging, then auth, then the handler.
❌ Logging the status without wrapping ResponseWriter — you can't read it back.
✅ Embed http.ResponseWriter and override WriteHeader (default to 200).
❌ Continuing after a short-circuit — writing 401 then still calling next sends two responses.
✅ return immediately after writing the early response.
🧠 Quick Recall
A(B(handler)) — which runs first on a request?
A (outermost) sees the request first, then B, then the handler — and the response unwinds back out through B then A.
handler never calls WriteHeader — logged status?
200 — Go sends 200 OK by default on the first write, so your wrapper must initialise its recorded status to 200.
🎯 Mini-Challenge: Request Counter
Write middleware that increments a counter per request and sets an X-Request-Count header to the new value. Wrap a handler with it, fire 3 requests, and print each count.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
// 🎯 MINI-CHALLENGE: a request-counter middleware
// Write middleware that increments a counter on every request and adds
// an "X-Request-Count" response header with the new value. Wrap a simple
// handler with it, send 3 requests, and print each count.
//
// ✅ Expected output:
// 1
// 2
// 3
hello := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "ok")
})
fmt.Println("handler ready:", hello != nil)
// your code here:
// count := 0
// counter := func(next http.Handler) http.Handler { ... count++ ... set header ... }
// wrap hello, loop 3 requests, print rec.Header().Get("X-Request-Count")
_ = httptest.NewRequest
}🎉 Lesson Complete!
- ✅ Middleware is func(http.Handler) http.Handler — wraps and composes
- ✅ Run logic before/after next.ServeHTTP; short-circuit by returning early
- ✅ The first middleware applied is the outermost layer
- ✅ Wrap ResponseWriter (override WriteHeader) to capture the status
- ✅ recover() in middleware turns panics into a clean 500
Practice quiz
What is the common signature of HTTP middleware in Go?
- func(http.Request) error
- func(http.Handler) http.Handler
- func() http.Handler
- func(http.ResponseWriter)
Answer: func(http.Handler) http.Handler. Middleware takes the next handler and returns a new handler: func(http.Handler) http.Handler.
How does middleware pass control to the wrapped handler?
- By returning
- By calling next.ServeHTTP(w, r)
- By panicking
- Automatically
Answer: By calling next.ServeHTTP(w, r). Calling next.ServeHTTP(w, r) invokes the next handler in the chain.
How can middleware short-circuit a request (e.g. reject it)?
- Call next twice
- Return early without calling next.ServeHTTP
- Throw an error
- Close the server
Answer: Return early without calling next.ServeHTTP. Writing a response and returning before calling next.ServeHTTP stops the chain — useful for auth checks.
When you apply several middlewares, which one is the outermost layer?
- The last applied
- The first applied
- The shortest one
- Order does not matter
Answer: The first applied. The first middleware applied wraps the rest, making it the outermost layer.
How do you capture the HTTP status code a handler writes?
- Read it from the request
- Wrap ResponseWriter and override WriteHeader
- It is impossible
- Use a global variable
Answer: Wrap ResponseWriter and override WriteHeader. Wrap http.ResponseWriter and override WriteHeader to record the status before delegating.
What turns a panic in a handler into a clean 500 response?
- os.Exit
- recover() inside deferred middleware
- panic() again
- log.Fatal
Answer: recover() inside deferred middleware. A deferred recover() in recovery middleware catches the panic and writes a 500 instead of crashing.
Why is http.Handler an interface a good fit for middleware?
- It is faster
- Any wrapper that satisfies it can be composed transparently
- It avoids imports
- It is required by the compiler
Answer: Any wrapper that satisfies it can be composed transparently. Because handlers are an interface, wrappers compose freely without callers knowing the difference.
Can middleware run logic both before and after the next handler?
- No, only before
- No, only after
- Yes — code before and after next.ServeHTTP
- Only with goroutines
Answer: Yes — code before and after next.ServeHTTP. Code before next.ServeHTTP runs on the way in; code after runs on the way out (e.g. timing).
Since http.ServeMux is itself an http.Handler, you can:
- Only wrap individual handlers
- Wrap the whole mux to apply middleware to every route
- Not use middleware with it
- Only wrap GET routes
Answer: Wrap the whole mux to apply middleware to every route. ServeMux satisfies http.Handler, so wrapping the mux applies middleware to all routes.
What is a typical cross-cutting concern handled by middleware?
- Business logic
- Logging, recovery, and CORS
- Database schema
- Struct definitions
Answer: Logging, recovery, and CORS. Logging, panic recovery, CORS, and auth are classic cross-cutting concerns done in middleware.
Continue this course
- Previous: Building a REST API
- Next: File I/O (os, bufio, io) — Read and write files, scan line by line, and copy streams with io.Copy
Frequently asked questions
What exactly is HTTP middleware in Go?
It's a function with the signature func(http.Handler) http.Handler: it takes the 'next' handler and returns a new handler that runs some logic before and/or after calling next.ServeHTTP. Because the input and output are both http.Handler, middleware composes — you can stack as many layers as you like around your real handler.
In what order do chained middleware run?
Wrapping is like nesting boxes. If you build logging(auth(handler)), a request hits logging first (outermost), then auth, then the handler, and the response unwinds back out through auth and logging. So the FIRST middleware you apply is the OUTERMOST and sees the request first and the response last. A helper that loops the list in reverse lets you list them outermost-first for readability.
How does middleware stop a request early?
It simply writes a response and returns WITHOUT calling next.ServeHTTP. For example, an auth middleware that finds no valid token calls http.Error(w, ..., 401) and returns; the inner handler never runs. This 'short-circuit' is how you enforce authentication, rate limits, and validation.
Why wrap http.ResponseWriter, and how?
The plain ResponseWriter doesn't expose the status code or bytes written after the fact, so logging or metrics middleware can't see them. You embed http.ResponseWriter in a small struct and override WriteHeader (and optionally Write) to record what passes through, then pass your wrapper to next.ServeHTTP. Default the status to 200 because a handler that never calls WriteHeader still returns 200.
What does recovery middleware do?
A panic in a handler would otherwise crash that request's goroutine and return an empty response (the server itself keeps running, but the client gets nothing useful). Recovery middleware wraps the call in a deferred recover(): if a panic happens it logs it and writes a clean 500 Internal Server Error, so one bad handler can't take down requests. Put it as the OUTERMOST middleware so it catches panics from every inner layer.
Can I use the same middleware with http.ServeMux?
Yes. ServeMux is itself an http.Handler, so you can wrap the WHOLE mux — wrappedMux := logging(recovery(mux)) — to apply middleware to every route, or wrap individual handlers before registering them for per-route middleware. Many teams apply cross-cutting concerns (logging, recovery, CORS) at the mux level and route-specific ones (auth on admin routes) per handler.