skip to content

Middleware and Request Flow

Middleware in Go is only a func(http.Handler) http.Handler, so ordering, request-scoped values and panic recovery are yours to write. Interviewers check what you know net/http does not give you.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

explore

questions

30

In net/http, how do you write a func(http.Handler) http.Handler wrapper, and where does next.ServeHTTP sit?

level: juniorimportance: must knowfreq 78%

answer

  1. same type in, same type out
  2. a closure that remembers next
  3. http.HandlerFunc adapts the closure
  4. the call splits in-code from out-code
  5. outer body once, inner body per request

basics

~20 s

A net/http middleware takes the next http.Handler and returns a new one. Inside its ServeHTTP it runs setup code, calls next.ServeHTTP(w, r) to invoke the wrapped handler, then runs teardown code after that call returns.

solid answer

~40 s

The convention is `func(next http.Handler) http.Handler`. The wrapper closes over `next` and returns a new handler, almost always an `http.HandlerFunc` closure, so it satisfies the same `http.Handler` interface the thing it wraps satisfies -- that sameness is what lets wrappers nest. Inside that closure, everything written *before* `next.ServeHTTP(w, r)` runs on the way in, `next.ServeHTTP(w, r)` runs the whole rest of the chain and the final handler, and everything after that line runs on the way out once the inner handler has returned. If the wrapper returns without calling `next.ServeHTTP`, nothing deeper in the chain runs at all. Note also that the outer function body executes once, at wiring time, while only the returned closure runs per request -- so one-off setup belongs outside the closure.

code

go · 11 lines
go
// Middleware is the shape every wrapper in the shared package uses.
type Middleware func(http.Handler) http.Handler

func WithTiming(next http.Handler) http.Handler {
	// this body runs once, when the chain is built
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now() // on the way in
		next.ServeHTTP(w, r) // the whole rest of the chain runs here
		log.Printf("%s %s took %s", r.Method, r.URL.Path, time.Since(start))
	})
}

go deeper

for a junior

Be ready to write the wrapper from memory: take next, return http.HandlerFunc, call next.ServeHTTP in the middle. Know that code above that call runs first and code below it runs after the inner handler finishes.

for a middle

Explain the mechanics: the closure captures next, http.HandlerFunc adapts a function to the interface, the outer body runs once at wiring time and the inner closure once per request, and next.ServeHTTP is an ordinary synchronous call.

for a senior

Show the judgment a reviewer applies: exactly one next.ServeHTTP per path, response mutations only before it, defer for cleanup that must survive a panic, and no shared mutable state captured across concurrent requests.

for a principal

Own the shape as a contract. When several services import one middleware package, insisting on plain func(http.Handler) http.Handler rather than a bespoke interface keeps every wrapper usable with any router, any http.Server and httptest, forever.

## The interface everything hangs off `net/http` has exactly one server-side abstraction: ``` type Handler interface { ServeHTTP(ResponseWriter, *Request) } ``` A middleware in Go is not a framework concept, a registered plugin, or an interceptor the runtime knows about. It is just a **function that takes an `http.Handler` and returns an `http.Handler`**: ``` func WithX(next http.Handler) http.Handler ``` Because the input and the output are the same type, the result can be fed straight into another wrapper of the same shape. That is the entire mechanism behind "middleware chains" in Go: ordinary function composition over one interface. ## The two function bodies, and why the distinction matters A wrapper has two nested bodies, and they run at completely different times. 1. **The outer body** runs once, when you build the chain at start-up. This is where you can do work you do not want to repeat per request: compile a regexp, read config, allocate a shared object. 2. **The returned handler's body** runs once per request. This is where the request-scoped work goes. The returned handler is usually built with `http.HandlerFunc`, the adapter type whose `ServeHTTP` method simply calls the function itself. So `http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ... })` turns a plain function into an `http.Handler`. The closure captures `next`, which is how the wrapper remembers what to call. ## Before, after, and never Inside the returned handler there are three positions, and each means something different: - **Before `next.ServeHTTP(w, r)`** -- runs on the way in, before the wrapped handler has seen the request. Anything that must influence the response (setting a response header, replacing the request via `r.WithContext`, rejecting the request outright) has to happen here. - **The `next.ServeHTTP(w, r)` call itself** -- this is a synchronous, ordinary function call. It does not return until the wrapped handler, and everything the wrapped handler wraps, has finished. It is not a "continue" signal to a pipeline; the rest of the chain literally runs inside your stack frame. - **After `next.ServeHTTP(w, r)` returns** -- runs on the way out. By then the inner handler has usually already written the response, so this position is for *observing*: measuring elapsed time, emitting a log line, releasing something you acquired on the way in. There is a fourth possibility: **not calling `next.ServeHTTP` at all**. A wrapper that writes a response and returns short-circuits the request -- nothing deeper in the chain, and not the final handler, ever runs. Go has no separate "abort" API for this; you just return. Code that must run on the way out *even if the inner handler panics* goes in a `defer` inside the returned handler rather than on a plain line after the call, because a panic unwinds past plain statements but still runs deferred functions. ## Parameterised wrappers When a wrapper needs configuration, the usual shape is a constructor that returns the wrapper: ``` func WithHeader(key, value string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set(key, value) next.ServeHTTP(w, r) }) } } ``` Now there are three nested bodies: the configuration call, the wiring call, and the per-request handler. A shared middleware package that several services import is nearly always written this way, so each service can supply its own values while the shape stays uniform. ## Why this shape and not a method or an interface You could define your own `Middleware` interface, and some codebases do (`type Middleware func(http.Handler) http.Handler` is a common named alias, purely for readability). But because the plain function type already composes and already fits every helper that accepts `http.Handler`, anything more elaborate buys nothing. A wrapper written this way works with any router that accepts an `http.Handler`, any `http.Server`, and `httptest` in tests, without adaptation. ## What a reviewer looks for Given a wrapper in a pull request, the things worth checking are: does it call `next.ServeHTTP` exactly once on every path where the request should continue; is anything that must be set on the response done *before* that call; is per-request state kept in the closure's local variables rather than in package-level variables shared by every concurrent request; and does the wrapper return early only where a short-circuit is genuinely intended.

  • What happens if your wrapper never calls next.ServeHTTP?
    The request is short-circuited: neither the rest of the chain nor the final handler runs, and the client gets whatever your wrapper wrote (or an empty 200 if it wrote nothing). Go has no separate abort call -- returning early is the abort. That is exactly how a rejection layer is written, so an accidental early return is a real bug class.
  • Why is the returned handler usually an http.HandlerFunc rather than a new named type?
    `http.HandlerFunc` is an adapter: it is a function type whose `ServeHTTP` method calls the function itself. Wrapping a closure in it produces an `http.Handler` in one line, with no struct, no field for `next`, and no separate method declaration. A named struct type is only worth it when the wrapper needs exported configuration or its own methods.
  • Should cleanup after the inner handler go on a plain line or in a defer?
    Use `defer` whenever the cleanup must happen even if the inner handler panics -- releasing a slot, closing something, emitting a final log line. A panic unwinds past plain statements written after `next.ServeHTTP` but still runs deferred functions. Plain lines are fine only for work that is meaningless when the request blew up.
  • Where do you put work that should happen once rather than per request?
    In the outer function body, before the `return http.HandlerFunc(...)`. That body executes a single time, when the chain is assembled at start-up. Anything inside the returned closure runs on every request and on many goroutines at once, so compiling patterns or reading configuration there wastes work and invites shared-state races.

Each wrapper is an envelope around the one inside it. Opening happens outermost-first on the way in; sealing happens innermost-first on the way back out.

saying these in an interview costs you the question

  • Thinks next.ServeHTTP schedules the handler asynchronously
  • Puts per-request state in a package-level variable
  • Sets a response header after next.ServeHTTP returns
  • Calls next.ServeHTTP twice on one request path
  • Believes net/http calls the inner handler automatically
  • Does per-request setup in the outer function body
open as a page

In a Go net/http middleware, how do you recognise a CORS preflight request and answer it?

level: juniorimportance: must knowfreq 55%

basics

~20 s

A CORS preflight is an OPTIONS request that also carries an Access-Control-Request-Method header. A Go middleware tests both, writes the Access-Control-Allow-Origin, -Methods and -Headers response headers, sends 204 via w.WriteHeader(http.StatusNoContent), and returns without calling next.ServeHTTP.

open as a page

What happens when an http.Handler in a Go net/http server panics?

level: juniorimportance: must knowfreq 62%

basics

~20 s

Go's net/http server recovers a panic raised by a handler, so the process keeps serving other requests. It prints the panic value and a stack trace to Server.ErrorLog and closes that one connection, so the client gets no HTTP status at all.

open as a page

What does (*http.Request).WithContext return, and why must middleware pass on that copy?

level: juniorimportance: must knowfreq 68%

basics

~20 s

WithContext returns a shallow copy of the request carrying the new context and leaves the original untouched. Middleware must reassign r = r.WithContext(ctx) and hand that copy to next.ServeHTTP, or the inner handler still sees the old context.

open as a page

Why compare an API shared secret with crypto/subtle.ConstantTimeCompare rather than Go's == operator?

level: middleimportance: must knowfreq 61%

basics

~20 s

Comparing strings with == stops at the first differing byte, so how long it takes depends on how much of the secret the caller guessed. subtle.ConstantTimeCompare examines every byte and returns 1 for equal or 0 otherwise, in time that does not depend on the contents.

open as a page

Why does a Go middleware's map of per-client rate limiters, filled on first request, break under concurrent load?

level: middleimportance: must knowfreq 55%

basics

~20 s

A Go server runs every request on its own goroutine, so an unguarded map is read and written concurrently. The runtime throws a fatal error on concurrent map writes, which recover cannot catch. Guard lookup and insert with one mutex.

open as a page

Why add recovery middleware to a Go http.Handler chain if net/http already recovers panics?

level: middleimportance: must knowfreq 54%

basics

~20 s

The server's own recovery only saves the process: it drops the connection with no status and logs one context-free line. Middleware recovers inside the request's goroutine, so it can return a real 500, log with the method and path, count the panic, and keep the connection reusable.

open as a page

Why does a wrapper struct embedding http.ResponseWriter fail optional-interface assertions the underlying writer passes?

level: middleimportance: must knowfreq 52%

basics

~20 s

Embedding the http.ResponseWriter interface promotes only its three methods, so the wrapper's own type satisfies nothing else. Assertions for optional capabilities silently take the false branch. Give the wrapper an Unwrap method returning http.ResponseWriter and let callers use http.NewResponseController.

open as a page

An http.ResponseWriter wrapper logs status 0 for many requests. Why, and how do you fix it?

level: middleimportance: must knowfreq 58%

basics

~20 s

Those handlers never called WriteHeader, so the wrapper's status field kept the int zero value while net/http sent an implicit 200. Seed the field with http.StatusOK when constructing the wrapper, or set it on the first Write.

open as a page

What does (*http.Request).BasicAuth return in Go, and when is its ok value false?

level: juniorimportance: should knowfreq 52%

basics

~20 s

BasicAuth on *http.Request returns username, password and ok. It reads the Authorization header, base64-decodes the credentials and splits them at the first colon. ok is false when the header is missing, is not the Basic scheme, or does not decode.

open as a page

In a Go rate-limiting HTTP middleware, what is the difference between an x/time/rate Limiter's Allow and Wait?

level: juniorimportance: should knowfreq 45%

basics

~20 s

Allow returns true or false immediately, so an over-limit request is shed with 429 straight away. Wait blocks the request's goroutine until a token frees up or the request context ends, queueing the caller instead of refusing it.

open as a page

Why must access-log middleware wrap http.ResponseWriter to record a response's status code?

level: juniorimportance: should knowfreq 48%

basics

~20 s

http.ResponseWriter is write-only: Header, Write and WriteHeader, with no getter for what was sent. Middleware passes the handler a struct that embeds the writer and overrides those methods, so it can record the status code and the byte count.

open as a page

Why does a net/http middleware setting a response header after next.ServeHTTP have no effect?

level: middleimportance: should knowfreq 46%

basics

~20 s

By the time next.ServeHTTP returns, the inner handler has already written the status line and headers, so the header map has been serialised and later edits are ignored. Anything that must reach the client has to be set before that call.

open as a page

When composing a slice of func(http.Handler) http.Handler wrappers in a loop, which one ends up outermost?

level: middleimportance: should knowfreq 60%

basics

~20 s

Whichever wrapper is applied last ends up outermost, because each application nests the previous result inside a new handler. To make the first slice element outermost, iterate the slice backwards, starting from the final handler.

open as a page

What does a Go CORS middleware write into Access-Control-Allow-Origin when several origins are allowed?

level: middleimportance: should knowfreq 48%

basics

~20 s

Exactly one origin: the request's Origin header echoed verbatim, after checking it against the allowed set. The header takes a single value, never a comma-separated list, and the middleware adds Vary: Origin because the response now differs per caller.

open as a page

How do you safely read a tenant id from r.Context() inside an http.Handler?

level: middleimportance: should knowfreq 52%

basics

~20 s

Use a comma-ok type assertion: id, ok := r.Context().Value(tenantKey{}).(string). The lookup returns an untyped value, so a bare assertion panics when nothing attached the key. Treat a missing value as a server misconfiguration, not a default.

open as a page

A preflight to your Go API fails once the SPA sends an Authorization header, though Allow-Origin and -Methods are set. Why?

level: seniorimportance: should knowfreq 42%

basics

~20 s

The browser names that header in Access-Control-Request-Headers on the preflight, and the response never lists it in Access-Control-Allow-Headers, so the preflight fails and the real request is never sent. Name Authorization there, or echo what was requested.

open as a page

An admin API compares its shared secret with ==; how do you show that timing leak is real and decide the fix?

level: seniorimportance: should knowfreq 36%

basics

~20 s

Write a Go benchmark whose inputs share 0, 8, 16 and 31 leading bytes with the secret and see whether the time climbs with the matching prefix. Local noise may hide the effect, and that is not a defence: the constant-time fix is one line, so apply it.

open as a page

A Go API's per-client rate-limiter map grows without bound as callers come and go. How do you fix it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Every unseen key adds an entry nothing removes, and on a public tier the key space is caller-controlled. Stamp entries with a lastSeen time and let a janitor goroutine delete ones idle longer than a full bucket refill.

open as a page

A Go HTTP service has recovery middleware, yet it still exits with a panic stack trace naming a handler. Why?

level: seniorimportance: should knowfreq 42%

basics

~20 s

The middleware's deferred recover only protects the goroutine running ServeHTTP. If a handler starts work with the go statement and that goroutine panics, no recover is on its stack, so the runtime prints a traceback to stderr and kills the whole process.

open as a page

Why can't an inner handler pass a tenant id back up to an outer middleware through r.Context()?

level: seniorimportance: should knowfreq 41%

basics

~20 s

Context values flow downward only. Attaching one builds a child the parent never learns about, and reassigning r rebinds a local variable pointing at a fresh copy, so the outer middleware still holds the original request and reads nothing.

open as a page

How do you set a free-tier API's per-client rate limit, and decide whether over-limit requests queue or get 429?

level: principalimportance: should knowfreq 35%

basics

~20 s

The number is a product decision bounded by measured capacity, not an engineering guess. Ship it in shadow mode first. Default to shedding with 429 on a public tier, and keep the value changeable without a deploy.

open as a page

For a Go service hosting other teams' handlers, do you recover panics into 500s or let the process crash?

level: principalimportance: should knowfreq 26%

basics

~20 s

Default to recovering into a 500 so one bad request does not drop everyone else's in-flight work, but make each panic expensive: a high-severity event with the route and an owner. Reserve crash-only for handlers whose failure could leave shared state unsound.

open as a page

Your platform team owns the http.ResponseWriter wrapper every service runs behind: how do you decide whether it forwards optional interfaces?

level: principalimportance: should knowfreq 30%

basics

~20 s

Decide from what handlers you cannot edit actually assert on the writer. Always implement Unwrap, since it costs nothing. Then choose between exact-capability forwarding, which preserves behaviour at the cost of generated complexity, and a migration to http.ResponseController that other teams have to fund.

open as a page

What does http.ErrAbortHandler do when a Go http.Handler panics with it?

level: middleimportance: nice to knowfreq 24%

basics

~20 s

http.ErrAbortHandler is a sentinel panic value meaning stop this response deliberately. Panicking with it aborts the response like any panic, but net/http suppresses the stack-trace log line, so it is the quiet way to give up on a request.

open as a page

When must middleware use (*http.Request).Clone instead of WithContext?

level: middleimportance: nice to knowfreq 34%

basics

~20 s

Use Clone whenever the middleware changes anything besides the context. WithContext returns a shallow copy sharing the Header map, URL and form values with the original, so a header written on the copy is visible through both; Clone deep-copies them.

open as a page

What changes when a net/http middleware chain wraps the whole ServeMux instead of individual routes?

level: seniorimportance: nice to knowfreq 38%

basics

~20 s

Wrapping the ServeMux puts the chain before routing, so it sees every request including ones that match nothing, but it cannot know which pattern matched. Wrapping individual routes runs after matching, with the pattern's path values available, but misses unmatched requests.

open as a page

Your Go CORS middleware answers the preflight with 204, but the browser still blocks the actual GET. Why?

level: seniorimportance: nice to knowfreq 30%

basics

~20 s

Almost always because the Access-Control-Allow-* headers are written only inside the preflight branch. The browser re-checks Access-Control-Allow-Origin on the real response, so every response the wrapper passes through, including 404s and 500s from inner handlers, has to carry it.

open as a page

A byte-counting http.ResponseWriter wrapper made io.Copy of a large file to the client slower. Why?

level: seniorimportance: nice to knowfreq 26%

basics

~20 s

net/http's own response writer implements io.ReaderFrom, and io.Copy uses that fast path. The wrapper's type does not, so io.Copy fell back to a 32 KiB buffered loop of Write calls. Declare ReadFrom on the wrapper, delegate, and add the returned count.

open as a page

Your Go service checks credentials per route rather than around the whole ServeMux; how do you decide which default is right?

level: principalimportance: nice to knowfreq 31%

basics

~20 s

Decide by what a route added six months from now does on its own. Wrapping the whole mux fails closed and turns the exceptions into one reviewable list. Per-route checks fail open, and are defensible only with a registry and a test proving every path is covered.

open as a page