Handlers Wrapping Handlers: Middleware in Go

The func(next http.Handler) http.Handler pattern — building logging and auth middleware, chaining them, capturing the status code with a ResponseWriter wrapper, and passing request-scoped values through context with typed unexported keys. Compiled and run against Go 1.26.5.

Every web service grows a set of concerns that apply to every request and belong to none of them: logging, timing, authentication, request IDs, panic recovery, CORS. You don’t want that code copied into every handler, and you don’t want a framework’s opinion about it either. Go’s answer is middleware, and the beautiful part is that it needs no framework at all — just the http.Handler interface you already have and one small function type. A middleware is a function that takes a handler and returns a new handler that wraps it. Stack a few of those and you have a pipeline. This chapter builds two real middlewares, chains them, captures the response status, and threads a value through the request with context. Every line was compiled and run against Go 1.26.5.

The shape

The entire pattern is this type signature:

func(next http.Handler) http.Handler

A middleware receives the next handler in the chain and returns a handler that does some work, then calls next.ServeHTTP to pass control inward. Because the thing it returns is itself an http.Handler, it can be fed to the next middleware, which is why they compose. Here’s the simplest useful one — logging with timing:

func logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		fmt.Printf("LOG %s %s in %v\n", r.Method, r.URL.Path, time.Since(start))
	})
}

Read the control flow: it records a start time, calls next to run the rest of the chain and the real handler, and then logs. Anything you write before next.ServeHTTP happens on the way in; anything after happens on the way out. That in-and-out structure is the whole mental model. http.HandlerFunc is the adapter that turns a plain function into an http.Handler — it’s a named function type with a ServeHTTP method that just calls itself, so you can hand a closure where an interface is wanted.

Chaining

A second middleware, auth, rejects requests without the right token and otherwise passes through:

func auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != "Bearer secret" {
			w.Header().Set("X-Auth", "rejected")
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return // short-circuit: never call next
		}
		w.Header().Set("X-Auth", "ok")
		ctx := context.WithValue(r.Context(), userKey, "ada")
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Two things to notice. When auth fails it writes a 401 and returns without calling next — a middleware that short-circuits simply doesn’t pass control inward, and the handlers behind it never run. When auth succeeds it calls next, but with a modified request carrying a context value (more on that below). To assemble the pipeline you nest the calls:

h := logging(auth(http.HandlerFunc(handler)))

Order matters and reads outside-in: logging is outermost, so it starts its timer first and logs last; auth runs next; the real handler is innermost. Running an authorized and an unauthorized request through it (over httptest) prints:

LOG GET /hello -> 200 in 19µs
  -> 200 X-Auth="ok" body="hello, ada"
LOG GET /hello -> 401 in 6µs
  -> 401 X-Auth="rejected"

The unauthorized request still gets logged — logging wraps auth, so it sees the 401 that auth produced on the way back out. That nesting can get hard to read past three or four layers, and real codebases usually write a tiny Chain(h, mw...) helper to flatten logging(auth(rateLimit(h))) into a slice. The mechanism underneath is exactly the nesting shown here.

Order isn’t just cosmetic; it’s a correctness decision. The canonical third middleware is panic recovery — a recover() in a deferred function that turns a handler panic into a 500 instead of crashing the process — and it has to sit outermost, wrapping everything, because a middleware can only catch a panic from code it called via next.ServeHTTP. Put recovery inside auth and a panic in auth escapes it. The same logic tells you request-ID or tracing middleware goes near the outside (so everything downstream can see the ID) while auth goes before the handlers it protects but after logging, so even rejected requests are recorded. Deciding the order is the design work; the wrapping is mechanical.

Capturing the status code

Look again at that log line: it reports -> 200 and -> 401. That status did not come for free, and the reason is a genuine sharp edge. By the time your handler calls w.WriteHeader(401), the status has gone out onto the connection — http.ResponseWriter gives you no method to read back what status was written. So a logging middleware that wants to record the status has to intercept the write. You do that with a small wrapper around the ResponseWriter:

type statusRecorder struct {
	http.ResponseWriter // embeds the real writer; inherits its methods
	status int
}

func (r *statusRecorder) WriteHeader(code int) {
	r.status = code            // remember it
	r.ResponseWriter.WriteHeader(code) // then forward to the real writer
}

By embedding http.ResponseWriter, the wrapper is a valid ResponseWriter — it inherits Write and Header unchanged — but it overrides WriteHeader to record the code before passing it along. The logging middleware then wraps w before calling next:

rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
// rec.status now holds whatever the handler wrote

The default of http.StatusOK matters: a handler that writes a body without ever calling WriteHeader implicitly sends a 200, and since our override never fired in that case, the recorder needs to already hold 200. This wrapper trick — embed the interface, override the one method you care about, forward the rest — is worth keeping in your toolbox well beyond logging.

The wrapper’s hidden cost: dropped interfaces

That recorder has a trap in it, and it is a nasty one because it fails silently. http.ResponseWriter is a small interface, but the concrete writer the server hands you also implements optional ones — http.Flusher for streaming a response as it’s produced (SSE, long polls, progress), http.Hijacker for taking over the raw connection (WebSockets), io.ReaderFrom for efficient file sends. Handlers reach these by type-asserting the writer: if f, ok := w.(http.Flusher); ok { f.Flush() }.

Here’s the problem. Your statusRecorder embeds http.ResponseWriter, so it satisfies that one interface — but only that one. Embedding doesn’t forward Flush, because the recorder’s static type is the interface, which has no Flush method to promote. So the moment a request passes through your middleware, the writer the handler sees is a *statusRecorder, and the http.Flusher assertion that would have succeeded on the raw writer now fails:

var raw http.ResponseWriter = /* the server's writer */
_, rawOK := raw.(http.Flusher)                    // true

var wrapped http.ResponseWriter = &statusRecorder{ResponseWriter: raw, status: 200}
_, wrappedOK := wrapped.(http.Flusher)             // false
raw ResponseWriter is a Flusher:       true
naive statusRecorder is a Flusher:     false

Nothing errors. The handler’s if _, ok := w.(http.Flusher); ok simply takes the false branch, the flush is quietly skipped, and streaming stops working through your middleware while working fine without it. This is a genuinely hard bug to find, because the handler is correct, the raw writer supports flushing, and only the invisible wrapper in between broke the chain.

The fix is to forward the methods you need explicitly. Add a Flush to the wrapper that delegates to the embedded writer when it supports it:

func (r *statusRecorder) Flush() {
	if f, ok := r.ResponseWriter.(http.Flusher); ok {
		f.Flush()
	}
}
flushRecorder (forwards Flush):        true

Now the assertion succeeds again. The general rule: any time you wrap http.ResponseWriter, you take on the job of forwarding every optional interface the underlying writer offeredFlush, Hijack, ReadFrom — or you silently strip capabilities from every handler behind you. This is exactly why the response-wrapping helpers in real router libraries are more than ten lines: they detect which optional interfaces the wrapped writer implements and expose the matching set. If your service streams anything, wrap with care.

Request-scoped values through context

The auth middleware stashed a username with context.WithValue, and the handler read it back:

user, _ := r.Context().Value(userKey).(string)

Every *http.Request carries a context.Context, and context.WithValue returns a new context with an added key-value pair. This is how middleware communicates with the handlers downstream of it: auth resolves the user once, puts it on the context, and every inner handler can read it without re-parsing the token. The value flows one way, inward, along with the request.

The catch — and it’s the reason this section exists — is the key. It’s tempting to write context.WithValue(ctx, "user", name) with a plain string. Don’t. Context keys share a single namespace across your entire program, including every third-party package in it, so two packages that both use the string "user" will silently clobber each other:

ctx = context.WithValue(ctx, "user", "ada")
ctx = context.WithValue(ctx, "user", "bob") // different package, same string key
fmt.Println(ctx.Value("user"))              // -> bob  (ada is gone)

The fix is to use an unexported named type as the key, so it’s unique to your package and cannot collide with anyone else’s — not even another package that happens to use the same underlying integer:

type ctxKey int
const userKey ctxKey = 0

Because ctxKey is unexported, no other package can construct that exact key, so no other package can read or overwrite your value. Verifying the collision and the fix side by side:

string key 'user': bob      // clobbered
keyA: ada                    // typed keys never collide
keyB: bob
string(0) lookup: <nil>      // a different-typed key sees nothing

Two typed keys with the same integer value 0 coexist happily, and a lookup with the wrong key type finds nothing. That is exactly the isolation a string key throws away.

One matter of taste worth stating, since context is easy to overuse: it’s meant for values that are genuinely request-scoped and cross API boundaries — an authenticated user, a request ID, a trace span. It is not a general-purpose bag for passing optional arguments into functions that could just take parameters. The signal that you’re reaching too far is a handler pulling half its inputs out of r.Context(); the values that belong there are the ones every layer might need and none should have to re-derive.

Final thoughts

Middleware in Go is a function func(next http.Handler) http.Handler that wraps a handler and returns a new one — work before next.ServeHTTP runs on the way in, work after runs on the way out, and a return before calling next short-circuits the chain. You compose them by nesting, logging(auth(handler)), reading outside-in. When you need the status code that a handler already wrote, embed http.ResponseWriter in a small wrapper and override WriteHeader to record it, defaulting to 200 — but remember that the wrapper silently drops http.Flusher/http.Hijacker unless you forward them, which breaks streaming through your middleware without a peep. And when middleware needs to hand a value downstream, put it on r.Context() with context.WithValue — but always behind an unexported typed key, because a plain string key lives in a global namespace and gets clobbered without a peep. No framework, no magic, just handlers wrapping handlers. Next we turn the program around and become the client.

Next: calling the outside world — outbound requests, the timeout that isn’t there by default, and reusing one client.

Comments