DEV Community

Cover image for Building a net/http Middleware Chain From Scratch in Go
Shayan Holakouee
Shayan Holakouee

Posted on

Building a net/http Middleware Chain From Scratch in Go

Every Go web framework ships with middleware. Chi has it. Gin has it. Echo has it. And every tutorial shows you how to use it.

But almost none of them show you how it actually works.

This article builds a production-grade middleware chain using nothing but the Go standard library. No framework, no magic. By the end you will have written request logging, authentication, panic recovery, and timeout middleware from scratch, and you will understand exactly why the pattern works the way it does.


The Foundation: What Is http.Handler?

Everything in Go's HTTP world revolves around one interface:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
Enter fullscreen mode Exit fullscreen mode

That's it. One method. Any type that implements ServeHTTP is an HTTP handler. This simplicity is intentional, and it's what makes middleware composable.

There's also http.HandlerFunc, a function type that implements Handler:

type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}
Enter fullscreen mode Exit fullscreen mode

This adapter lets you write a plain function and treat it as a Handler. You'll use this constantly.


The Middleware Signature

A middleware is a function that takes a Handler and returns a Handler:

type Middleware func(http.Handler) http.Handler
Enter fullscreen mode Exit fullscreen mode

That's the whole pattern. Middleware wraps a handler, intercepts the request, does something before or after calling the next handler, and passes control along.

Here's the skeleton every middleware follows:

func MyMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // do something before
        next.ServeHTTP(w, r)
        // do something after
    })
}
Enter fullscreen mode Exit fullscreen mode

The next handler is the thing being wrapped. You call it explicitly. This is the key insight: Go middleware is not magic. It's just nested function calls, composed at setup time.


Building the Chain

Managing middleware manually gets messy fast:

// This is readable but doesn't scale
handler := LoggingMiddleware(AuthMiddleware(RecoveryMiddleware(myHandler)))
Enter fullscreen mode Exit fullscreen mode

Let's write a proper Chain that takes a slice of middleware and applies them in order:

package middleware

import "net/http"

type Middleware func(http.Handler) http.Handler

func Chain(middlewares ...Middleware) Middleware {
    return func(final http.Handler) http.Handler {
        for i := len(middlewares) - 1; i >= 0; i-- {
            final = middlewares[i](final)
        }
        return final
    }
}
Enter fullscreen mode Exit fullscreen mode

The reverse iteration matters: if you want request logging to run first (outermost), it needs to wrap everything else. Applying in reverse order ensures the first middleware in your list is the first one that sees the request.

Usage:

chain := middleware.Chain(
    RecoveryMiddleware,
    LoggingMiddleware,
    AuthMiddleware,
)

http.Handle("/api/", chain(myAPIHandler))
Enter fullscreen mode Exit fullscreen mode

Clean, ordered, easy to reason about.


Middleware 1: Request Logging

The most common middleware. We want to log the method, path, status code, and duration of every request.

The tricky part: http.ResponseWriter doesn't expose the status code after it's been written. We need to wrap it:

package middleware

import (
    "log/slog"
    "net/http"
    "time"
)

type responseWriter struct {
    http.ResponseWriter
    status int
    written bool
}

func (rw *responseWriter) WriteHeader(code int) {
    if !rw.written {
        rw.status = code
        rw.written = true
        rw.ResponseWriter.WriteHeader(code)
    }
}

func (rw *responseWriter) statusCode() int {
    if !rw.written {
        return http.StatusOK // default if WriteHeader was never called
    }
    return rw.status
}

func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        wrapped := &responseWriter{ResponseWriter: w}
        next.ServeHTTP(wrapped, r)

        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", wrapped.statusCode(),
            "duration", time.Since(start),
            "remote_addr", r.RemoteAddr,
        )
    })
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here:

  • We embed http.ResponseWriter in our wrapper struct so all other methods (Header(), Write(), etc.) delegate to the original automatically.
  • We guard against double calls to WriteHeader, which handlers can do accidentally.
  • We use log/slog (Go 1.21+) for structured logging. If you're on an older version, swap in your preferred logger.

Middleware 2: Panic Recovery

A panic in a goroutine serving a request will crash the entire server if not recovered. This middleware catches panics and turns them into 500 responses:

package middleware

import (
    "log/slog"
    "net/http"
    "runtime/debug"
)

func RecoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                slog.Error("panic recovered",
                    "error", err,
                    "stack", string(debug.Stack()),
                )
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

debug.Stack() gives you the full goroutine stack trace at the point of the panic, which is invaluable for debugging production incidents.

Put RecoveryMiddleware first (outermost) in your chain. It needs to wrap everything so nothing escapes.


Middleware 3: Authentication

This one reads a Bearer token, validates it, and puts the claims into the request context so downstream handlers can read them without re-doing the work.

package middleware

import (
    "context"
    "net/http"
    "strings"
)

type contextKey string

const UserIDKey contextKey = "userID"

func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        header := r.Header.Get("Authorization")
        if !strings.HasPrefix(header, "Bearer ") {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }

        token := strings.TrimPrefix(header, "Bearer ")
        userID, err := validateToken(token) // your validation logic
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }

        ctx := context.WithValue(r.Context(), UserIDKey, userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Reading it in a handler
func myHandler(w http.ResponseWriter, r *http.Request) {
    userID, ok := r.Context().Value(UserIDKey).(string)
    if !ok {
        http.Error(w, "missing user", http.StatusInternalServerError)
        return
    }
    // use userID
}
Enter fullscreen mode Exit fullscreen mode

A few design choices worth explaining:

Why a custom contextKey type? Context keys are compared by value and type. Using a plain string like "userID" means any package could accidentally read or overwrite your value. A private contextKey type scoped to your package makes collisions impossible.

Why return early on auth failure? The middleware should short-circuit: if auth fails, call http.Error and return without calling next.ServeHTTP. Forgetting the return after the error write is a classic bug that leads to handlers running even when auth failed.


Middleware 4: Request Timeout

Long-running requests can exhaust your server's connection pool. This middleware cancels the request context after a deadline:

package middleware

import (
    "context"
    "net/http"
    "time"
)

func TimeoutMiddleware(timeout time.Duration) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ctx, cancel := context.WithTimeout(r.Context(), timeout)
            defer cancel()

            done := make(chan struct{})
            go func() {
                next.ServeHTTP(w, r.WithContext(ctx))
                close(done)
            }()

            select {
            case <-done:
                // handler finished in time
            case <-ctx.Done():
                http.Error(w, "request timeout", http.StatusGatewayTimeout)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Note that TimeoutMiddleware takes a time.Duration and returns a Middleware. This is the factory pattern: when a middleware needs configuration, wrap it in a function that accepts that config and returns the actual middleware. Clean and composable.

Usage:

chain := middleware.Chain(
    RecoveryMiddleware,
    LoggingMiddleware,
    middleware.TimeoutMiddleware(5*time.Second),
    AuthMiddleware,
)
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

Here's a full, working example:

package main

import (
    "fmt"
    "net/http"
    "time"

    "yourmodule/middleware"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
    userID, _ := r.Context().Value(middleware.UserIDKey).(string)
    fmt.Fprintf(w, "Hello, %s\n", userID)
}

func main() {
    chain := middleware.Chain(
        middleware.RecoveryMiddleware,
        middleware.LoggingMiddleware,
        middleware.TimeoutMiddleware(5*time.Second),
        middleware.AuthMiddleware,
    )

    mux := http.NewServeMux()
    mux.Handle("/hello", chain(http.HandlerFunc(helloHandler)))

    http.ListenAndServe(":8080", mux)
}
Enter fullscreen mode Exit fullscreen mode

The execution order for a request to /hello:

RecoveryMiddleware (enter)
  LoggingMiddleware (enter, start timer)
    TimeoutMiddleware (enter, start context)
      AuthMiddleware (enter, validate token)
        helloHandler (run)
      AuthMiddleware (exit)
    TimeoutMiddleware (exit)
  LoggingMiddleware (exit, log result)
RecoveryMiddleware (exit)
Enter fullscreen mode Exit fullscreen mode

Each middleware wraps the next. The chain is just deeply nested function calls, resolved at request time.


When to Reach for a Framework

This pattern scales well. But there are cases where the standard library genuinely falls short:

  • Path parameters: net/http's ServeMux gained basic wildcard support in Go 1.22 (/users/{id}), but complex routing (regex, optional segments) still requires a router like Chi or httprouter.
  • Middleware on route groups: applying middleware to /api/ but not /healthz requires careful mux setup or a router that supports route groups natively.

For most APIs, the standard library plus a lightweight router like Chi (which uses this exact middleware pattern under the hood) is the right call.


What You Built

From scratch, using only the standard library:

  • A reusable Chain function for composing middleware in order
  • Structured request logging with response code capture
  • Panic recovery with stack traces
  • Token authentication with context propagation
  • Configurable request timeouts

And more importantly: you now know what a framework's middleware stack actually is. It's this. Just wrapped in a nicer API.


If this was useful, the obvious next step is reading the source of Chi's middleware package. It follows exactly this pattern and is clean, readable Go. Link in the tags above.

Top comments (0)