net/http and the Middleware Pattern

Strip most Go web frameworks (Gin, Echo, chi and the like) far enough and you hit net/http; Fiber, built on fasthttp, is an exception. Once http.Handler and ServeHTTP click, you can see what a framework adds on top, and what it hides.

Correction (2 November 2026, 18:45 Taipei): “every Go web framework sits on net/http” now reads “most”, with Fiber noted as an exception; the panic section now says net/http already recovers handler panics, that Recover middleware controls the response and logging, and that it cannot turn a response into a 500 once something has been written. Suggested by Codex (gpt-6-astra), checked by Claude.

http.Handler and ServeHTTP

The whole net/http server abstraction is a single interface:

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

Anything with a ServeHTTP method is a Handler: http.ServeMux, third-party frameworks built on net/http, your own handlers, all of them. The common shortcut is http.HandlerFunc: a function type carrying a ServeHTTP method, so a plain function can be a Handler without a struct:

type HandlerFunc func(http.ResponseWriter, *http.Request)

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r) // the method just calls itself
}

“A method on a function type” is the Go idiom the whole middleware pattern rests on.

Middleware is a Handler wrapping a Handler

Middleware is no magic; it’s a function that takes a Handler and returns one:

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

The signature func(http.Handler) http.Handler is the entire pattern. It injects behaviour around the call to next — one wrap, one responsibility.

Chaining with closures

To stack several, wrap one inside another. A closure-based helper:

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) // wrap in reverse so the first listed runs outermost
    }
    return h
}

handler := Chain(mux, Logging, Auth) // a request hits Logging, then Auth

It’s an onion: the outermost layer runs first. A framework (gortex included) just generalises this and bolts routing and a context onto it.

defer and recover

net/http already recovers a panic inside ServeHTTP: it logs a stack trace and closes the HTTP/1 connection (or sends an HTTP/2 RST_STREAM), and the server carries on serving other requests. A custom Recover middleware decides what the client gets and what is logged, for example a 500. A panic in a goroutine started by the handler is outside this protection and still brings the whole program down:

func Recover(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

recover only works when called inside a deferred function. defer runs on function exit, including panic unwinding, so it’s well placed to catch it. It can only produce a 500 if nothing has been written yet, though: if the handler has already written part of the body before panicking, the status is already 200 and http.Error just appends the message.

From Effective Go: Functions, defer

The middleware pattern feeds on Go treating functions as first-class values: pass them, return them, store them in a slice. Effective Go’s Functions also covers multiple return values and named results.

defer is the cleanup idiom Effective Go singles out: schedule “close the file, unlock the mutex, finish up” at the function’s exit, written next to where you opened the resource so no early return can skip it. Two things to remember: multiple defers run LIFO, and arguments are evaluated at the defer statement, not when the call finally runs.

Notes

  • net/http’s core is one interface: http.Handler / ServeHTTP, and frameworks such as Gin, Echo and chi are built on it.
  • Middleware is just func(http.Handler) http.Handler; one wrap, one behaviour.
  • Chain with closures; the order is an onion, outermost first.
  • net/http already recovers handler panics; a custom defer + recover controls the 500 and the logging, and only works if nothing has been written yet.
  • Next up, Struct Tags and reflect lays the groundwork for declarative routing.

Further reading: the series goes on to dissect the gortex framework built on these ideas — source at yshengliao/gortex.


Ideas and technical judgement by Sheng; drafted with Claude · Go 1.23 (latest release at the time) · compiled retroactively · part of the 2026-06-13 blog renovation, paint still drying.