4 min read
On this page

HTTP Handlers & Routing

Go's net/http package ships a production-quality HTTP server in the standard library. No frameworks required. The handler interface is simple, the routing got a major upgrade in Go 1.22, and the middleware pattern is elegant. Most Go APIs never need anything beyond what ships with the language.

The http.Handler Interface

Everything in net/http revolves around a single interface:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Any type that implements ServeHTTP can handle HTTP requests. This is the foundation of Go's entire web stack.

type healthHandler struct{}

func (h healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status": "ok"}`))
}

func main() {
    http.Handle("/health", healthHandler{})
    log.Fatal(http.ListenAndServe(":8080", nil))
}

http.HandlerFunc: The Shortcut

Most handlers are simple functions. Go provides http.HandlerFunc to avoid defining a struct every time:

type HandlerFunc func(ResponseWriter, *Request)

HandlerFunc implements Handler by calling itself. This lets you register plain functions:

func healthCheck(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status": "ok"}`))
}

func main() {
    http.HandleFunc("/health", healthCheck)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

http.HandleFunc is a convenience that wraps your function in a HandlerFunc and registers it on the DefaultServeMux.

DefaultServeMux vs Custom Mux

http.ListenAndServe(":8080", nil) uses DefaultServeMux, a package-level global. This works for simple apps but has problems:

  • Any imported package can register routes on it (security risk)
  • No way to isolate route groups
  • Hard to test

Always use a custom mux in production:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", healthCheck)
    mux.HandleFunc("GET /api/users", listUsers)
    mux.HandleFunc("POST /api/users", createUser)

    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  60 * time.Second,
    }
    log.Fatal(server.ListenAndServe())
}

Using http.Server directly also gives you control over timeouts, which you must set in production to prevent slowloris attacks.

Go 1.22 Enhanced Routing

Before Go 1.22, the built-in mux was too limited for real APIs. You needed third-party routers for method matching and path parameters. Go 1.22 changed that.

Method Patterns

Prefix your pattern with the HTTP method:

mux.HandleFunc("GET /api/users", listUsers)
mux.HandleFunc("POST /api/users", createUser)
mux.HandleFunc("DELETE /api/users/{id}", deleteUser)

A request with the wrong method gets an automatic 405 Method Not Allowed.

Path Parameters

Use {name} in patterns and extract with r.PathValue:

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    // Use id to look up the user
}

mux.HandleFunc("GET /api/users/{id}", getUser)

Wildcard Suffix

Use {name...} to match the rest of the path:

mux.HandleFunc("GET /files/{path...}", serveFile)

func serveFile(w http.ResponseWriter, r *http.Request) {
    filePath := r.PathValue("path")
    // filePath could be "docs/readme.txt"
}

Precedence Rules

More specific patterns take priority over less specific ones:

mux.HandleFunc("GET /api/users/{id}", getUser)     // matches /api/users/42
mux.HandleFunc("GET /api/users/me", getCurrentUser) // matches /api/users/me exactly

The exact match /api/users/me wins over the wildcard {id}.

The Middleware Pattern

Middleware in Go follows a simple signature:

func(http.Handler) http.Handler

A middleware takes a handler, wraps it with additional behavior, and returns a new handler:

func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request",
            "method", r.Method,
            "path", r.URL.Path,
            "duration", time.Since(start),
        )
    })
}

func requireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        // Validate token, add user to context...
        next.ServeHTTP(w, r)
    })
}

Chaining Middleware

Middleware composes naturally. Apply them in order -- the outermost middleware runs first:

func chain(handler http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        handler = middlewares[i](handler)
    }
    return handler
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/users", listUsers)

    wrapped := chain(mux, logging, requireAuth, cors)
    log.Fatal(http.ListenAndServe(":8080", wrapped))
}

The request flows through: cors -> requireAuth -> logging -> mux -> handler.

You can also apply middleware to specific routes:

mux.Handle("GET /api/admin/users", requireAuth(http.HandlerFunc(listAdminUsers)))
mux.HandleFunc("GET /health", healthCheck) // No auth needed

When net/http Is Enough vs When You Need More

net/http Is Enough When

  • Your API has straightforward routing (most APIs do)
  • You are on Go 1.22+ and have method patterns and path parameters
  • You do not need route groups with shared middleware
  • You do not need automatic OpenAPI generation

Consider Chi When

  • You want route groups with scoped middleware
  • You want a familiar Express/Sinatra-style API
  • Chi uses the standard http.Handler interface, so it is a thin layer

Consider Gin When

  • You need maximum routing performance (radix tree router)
  • You want built-in binding, validation, and rendering
  • You accept a non-standard handler signature (gin.Context)
Complexity    Tool
-----------------------------------------
Simple tool   net/http + Go 1.22 routing
Medium API    Chi (standard-compatible)
Large API     Chi or Gin (route groups)

The key insight: Go 1.22 moved the bar significantly. What used to require Chi or Gorilla Mux is now built in.

Common Pitfalls

  • Forgetting to set timeouts on http.Server. The zero-value server has no timeouts. Every production server needs ReadTimeout, WriteTimeout, and IdleTimeout.
  • Using DefaultServeMux in production. Any imported package can register handlers on it. Always create your own mux.
  • Writing middleware that does not call next.ServeHTTP. If your middleware returns early (like auth failure), that is correct. But if it should continue the chain and you forget to call next.ServeHTTP, the request silently dies.
  • Assuming pattern order matters. Go 1.22's mux uses specificity, not registration order. More specific patterns always win.
  • Not reading the response after calling http.Error. http.Error does not stop execution. You must return after it, or your handler keeps running.

Key Takeaways

  • http.Handler is a one-method interface. Everything builds on it.
  • http.HandlerFunc lets you use plain functions as handlers.
  • Always use a custom mux and http.Server with timeouts in production.
  • Go 1.22 added method patterns (GET /path) and path parameters ({id}), eliminating most reasons to use third-party routers.
  • Middleware follows the func(http.Handler) http.Handler pattern and composes by wrapping.
  • Start with net/http. Reach for Chi or Gin only when you genuinely need route groups or features beyond what the standard library offers.