Skip to content
intermediate Phase 13 · HTTP Services, JSON, and Middleware

Middleware and Handler Chaining

Wrap handlers with logging, recovery, and authentication layers that compose.

45m
0 problems
Topic Progress 0%

The Same Three Lines in Every Handler

Every handler in the service starts the same way: note the start time, recover from panics, stamp a request id, check auth. Someone writes those lines in twenty handlers, gets them slightly different in three of them, and the day a panic happens inside the auth check of handler #17, it takes down the connection with no log line.

The fix is to notice the shape of the duplication: wrap the handler, run something before it, run something after it, and return a handler. That shape has a name and a type:

func Logging(log *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}

			next.ServeHTTP(sw, r)

			log.InfoContext(r.Context(), "request",
				"method", r.Method,
				"path", r.URL.Path,
				"status", sw.status,
				"dur", time.Since(start),
			)
		})
	}
}

type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

Read the outer function as configuration: Logging(log) runs once at wiring time and closes over the logger; the returned function does the wrapping. You get one logger per middleware instead of a global, and tests construct the same middleware with a test logger.

The statusWriter exists because the handler writes through your wrapper, not the raw writer — without overriding WriteHeader every log would say 200 regardless of what happened. One caveat the override exposes: handlers that stream (SSE), hijack connections, or push resources check for http.Flusher, http.Hijacker, or http.Pusher on the writer. An embedded http.ResponseWriter alone fails those assertions, and the handler silently loses the capability. For those endpoints, forward the optional interfaces explicitly or use http.NewResponseController(w).Flush() from inside the handler, which abstracts the lookup.

The middleware contract composes because it speaks http.Handler end to end — the same interface the server mounts and the mux routes (Interfaces and Method Sets). Nothing about logging knows it is sitting in front of users, and nothing about auth knows there is a logger behind it. What decides whether the whole stack behaves is the order the layers wrap each other, which is where subtle production bugs live.

Wrapping Order Is Execution Order — Read It Backwards

A team writes the stack like this and argues for an hour about why request ids are missing from panic logs:

h := routes()
h = Authentication(h)
h = RequestID(h)
h = Logging(h)

The mental model that ends the argument: the last middleware applied is the outermost layer — the first to see the request, the last to see the response. Draw it as an onion:

request -> Logging -> RequestID -> Authentication -> routes() -> response

Each arrow down runs before the handler; each arrow back up runs after. The code above gives you exactly that sequence, because Logging wraps everything wrapped so far. A declarative chain function makes the intent readable instead of assembled by hand:

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
}

// chain(routes(), Logging(log), Recovery(log), Authentication(validator))
// runs: Logging -> Recovery -> Authentication -> routes

With mws listed outside-in, the loop wraps right to left so the first listed layer ends up outermost. Now order is data you review in one line, not a sequence of assignments to re-derive.

Ordering rules worth committing to:

  • Logging outermost (or nearly). It measures the full duration and observes the final status — including whatever the recovery layer decided. Logging inside recovery misses the panic response; logging inside auth skips unauthenticated traffic you may still want to count.
  • Recovery just inside logging. It must wrap handlers and middleware that can panic, so place it before anything risky runs. A panic caught here becomes a clean 500; a panic with no recovery lets net/http handle it — the server logs to stderr and closes the connection with no response, so the client sees an empty reply and your structured logs see nothing.
  • Auth before anything that uses the identity, outside anything that should run for anonymous traffic. Health checks and metrics endpoints often bypass auth entirely — mounting them on a separate branch of the mux is cleaner than an if path == "/healthz" escape hatch inside the middleware.

Two composition mistakes surface in review. Reusing a middleware instance per request (holding request-scoped state in the closure) races when the same stack serves concurrent requests — closure state is configuration, request state belongs in the *http.Request. And calling chain inside a handler instead of at wiring time re-creates every layer per request, throwing away the configuration step that made the pattern cheap.

Passing Identity Without Widening Every Signature

Authentication middleware validates the token and now needs to tell handlers who the caller is. The tempting refactor: change every handler to func(w, r, user User). It violates the http.HandlerFunc contract the mux requires, ripples through every route, and forces each layer in between to carry a parameter it does not use.

Go's established answer: store the identity on the request's context. The context travels with *http.Request, requires no signature change, and dies with the request — no pooling, no cleanup.

package auth // unexported key: nobody outside can forge it

type ctxKey struct{}

type Principal struct {
	UserID int64
	Roles  []string
}

func Middleware(verify func(token string) (Principal, error)) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
			p, err := verify(tok)
			if err != nil {
				http.Error(w, "unauthorized", http.StatusUnauthorized)
				return
			}
			ctx := context.WithValue(r.Context(), ctxKey{}, p)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func PrincipalFrom(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(ctxKey{}).(Principal)
	return p, ok
}

// in a handler
func handleDelete(w http.ResponseWriter, r *http.Request) {
	p, ok := auth.PrincipalFrom(r.Context())
	if !ok { // defensive: this route is mounted behind auth
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	if !p.Can("orders:delete") {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}
	// ...
}

The unexported ctxKey struct{} is the detail that prevents an entire bug class. Context keys compare by interface equality: if keys were exported strings like "user", any package — or any dependency's package — using the same string would overwrite or read your identity. A zero-size unexported struct type is forgeable only inside auth, so collisions cannot compile.

Three rules keep context honest. Values go in with r.WithContext(ctx) — never mutate r.Context() in place; the request value is what handlers downstream receive. Only cross-cutting, request-scoped data belongs there (identity, request id, trace id); business inputs travel as explicit parameters, because a context lookup hides a function's real dependencies (Context and Cancellation). And readers always handle the missing case — a route mounted outside the auth branch gets ok == false, which is a 401, not a panic.

With identity flowing, the last layer to place is the one that protects the whole stack from its own bugs: recovery and timeouts as middleware, not scattered defers.

Recovery and Timeouts: Middleware That Owns Failure

A handler panics on a nil map read at 3 a.m. What each layer does next decides whether the incident is a log line or a support ticket.

Without recovery, net/http catches the panic at the connection goroutine: it logs the stack to stderr and closes the connection without writing a status. The client gets an empty response or a reset; your structured logs, metrics, and tracing see nothing, because the panic never became an event in your system. The recovery middleware turns it into all three — a 500 to the client, an error log with stack, a metrics increment:

func Recovery(log *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if rec := recover(); rec != nil {
					log.ErrorContext(r.Context(), "panic recovered",
						"err", rec,
						"path", r.URL.Path,
						"stack", string(debug.Stack()),
					)
					http.Error(w, "internal error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

One honesty note: if the handler already wrote the response header before panicking, http.Error cannot change the status — Go prints a superfluous WriteHeader warning and the client still sees the partial body. Recovery cannot un-write bytes; it can only ensure the stack is logged and the connection does not silently die. That is still the difference between debugging and guessing.

Timeouts follow the same logic. http.TimeoutHandler wraps a handler with a wall-clock limit and answers 503 when it fires, but it buffers the response — bad for streaming endpoints — and it does not cancel the handler's work; the database query keeps running after the client got 503. Prefer propagating a deadline through r.Context(): the server's read/write timeouts bound the connection, the handler passes r.Context() into every downstream call, and a cancelled context actually stops the work. Middleware's job is setting the deadline; context's job is honoring it.

Assemble the full stack in one reviewable line — chain(routes(), RequestID, Logging, Recovery, Timeouts, Authentication) — then take the same composition to tests: each layer is independently exercisable with httptest, and a regression in ordering shows up as a failing assertion rather than a Friday surprise (Testing Handlers with httptest).

Quiz

1. Code applies middleware as: h = A(h); h = B(h); h = C(h); then serves h. In what order does a request traverse A, B, and C?

Question 1 options

2. Access logs show status 200 for every request, including ones where handlers return 404 or 500. What is the most likely cause?

Question 2 options

3. A handler panics with no recovery middleware. What does the client experience, and what do your structured logs capture?

Question 3 options

4. Auth middleware stores the caller as context.WithValue(r.Context(), "user", p). Why is the string key a defect?

Question 4 options

Flashcards

Question

What is the middleware signature, and why does it return a function instead of taking the logger as an argument?

Answer

func(http.Handler) http.Handler. The outer function runs once at wiring time and closes over configuration (logger, validator, timeout), returning the actual wrapper — so configuration is explicit at startup, per-request state stays on the request, and the result composes with any handler.

Question

Given chain(routes(), Logging, Recovery, Authentication), what runs first on an incoming request and what runs last on the way out?

Answer

Logging runs first on the way in (outermost) and last on the way out; Recovery follows it, then Authentication, then routes(). Down the onion on request, back up on response — the wrapper closest to the network owns timing and final status.

Question

Why must a logging middleware wrap the recovery middleware rather than sit inside it?

Answer

So the log line still fires when a panic is recovered: recovery converts the panic into a 500, and logging — outside — records that 500 with duration. If logging sat inside recovery, the panic would skip it entirely and the incident would never appear in your request logs.

Question

What optional interfaces does a naive ResponseWriter wrapper silently break, and what is the modern remedy?

Answer

Handlers asserting http.Flusher, http.Hijacker, or http.Pusher fail against an embedded-only wrapper, breaking SSE, websockets, and HTTP/2 push. Forward those interfaces explicitly, or have handlers call http.NewResponseController(w).Flush(), which finds the capability behind the wrapper.

Question

Where should request-scoped state live — closure or context — and where should configuration live?

Answer

Configuration (loggers, verifiers, timeouts) closes over the middleware at wiring time; request-scoped state (identity, request id) goes into r.Context() via r.WithContext. Closures holding request state race across concurrent requests; contexts without configuration force per-request setup.

Revision Notes

Key Takeaways

  • 1. Middleware is just http.Handler wrapped in a function — that is why it composes with muxes, servers, and other middleware unchanged
  • 2. Wrapping order is execution order: the last middleware applied runs first and owns timing and status logging
  • 3. Without a recovery layer, panics bypass your logging and metrics entirely — net/http only writes to stderr and closes the connection
  • 4. Request identity travels in context under an unexported key type; business parameters stay in explicit function signatures
  • 5. Config closes over middleware at wiring time, request state rides the request — mixing the two races under load

Interview Tips

  • • "Explain middleware ordering" — draw the onion: last applied runs first; justify logging-outermost and recovery-inside with the panic-log scenario
  • • "How do you log response status?" — the WriteHeader wrapper, then volunteer the Flusher/Hijacker gap for streaming endpoints
  • • "How does auth pass the user to handlers?" — context value with unexported key, PrincipalFrom helper, and why string keys are forgeable
  • • "Recovery vs net/http's built-in panic handling" — connection closes with no response and no structured log; middleware yields 500 + stack in your pipeline

Cheat Sheet

Middleware and Handler Chaining

Shape and order:

Piece Rule
signature func(http.Handler) http.Handler — config in the outer closure
order last applied = outermost = first to see the request
logging outermost: owns duration + final status
recovery inside logging, around everything that can panic
auth before handlers needing identity; anonymous routes mounted outside it

Code template:

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
}
h := chain(routes(), Logging(log), Recovery(log), auth.Middleware(verify))

Status capture: wrap ResponseWriter, override WriteHeader to record the code; forward Flusher/Hijacker or use http.NewResponseController.

Identity: type ctxKey struct{} unexported in the auth package; publish with r.WithContext(context.WithValue(...)); read with a PrincipalFrom(ctx) (Principal, bool) helper that handles absence.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading