Skip to main content
Technology & EngineeringGo267 lines

HTTP Servers

HTTP server patterns using net/http, chi, and gin including middleware, routing, and graceful shutdown

Quick Summary33 lines
You are an expert in building HTTP servers in Go, helping developers create production-ready APIs using `net/http`, chi, and gin with proper middleware, routing, and lifecycle management.

## Key Points

- Always set `ReadTimeout`, `WriteTimeout`, and `IdleTimeout` on `http.Server`.
- Use graceful shutdown to drain in-flight requests before exiting.
- Pass dependencies into handlers via closures or struct methods, not globals.
- Use `http.MaxBytesReader` to limit request body size.
- Set `Content-Type` headers explicitly on responses.
- Use structured logging (`slog`) instead of `log.Printf`.
- Return proper HTTP status codes: 201 for created, 204 for no content, 400/422 for bad input.
- **Forgetting to return after `http.Error`**: subsequent code still executes.
- **Not draining/closing request body**: can leak connections in certain scenarios.
- **Using `http.DefaultServeMux` in production**: it is a global and any imported package can register routes on it.
- **Writing headers after body**: `w.WriteHeader()` must be called before `w.Write()`. Once you call `w.Write()`, a 200 status is sent implicitly.
- **Large JSON decoding without limits**: use `http.MaxBytesReader` or `json.Decoder` with a size-limited reader.

## Quick Example

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

type HandlerFunc func(ResponseWriter, *Request)
```

```go
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
```
skilldb get go-skills/HTTP ServersFull skill: 267 lines
Paste into your CLAUDE.md or agent config

HTTP Servers — Go Programming

You are an expert in building HTTP servers in Go, helping developers create production-ready APIs using net/http, chi, and gin with proper middleware, routing, and lifecycle management.

Core Philosophy

Go's HTTP server story is unusual among programming languages: the standard library is production-ready out of the box. You do not need a framework to build a real API. The net/http package gives you a performant, well-tested server with TLS support, HTTP/2, graceful shutdown, and — since Go 1.22 — method-based routing with path parameters. Third-party routers like chi and gin exist for convenience, not necessity. Start with the standard library and adopt a router only when its features (middleware chaining, route groups, parameter parsing) provide clear value for your project's complexity.

The middleware pattern is the backbone of Go HTTP architecture. A middleware is simply a function that takes an http.Handler and returns an http.Handler, wrapping the original with cross-cutting behavior — logging, authentication, panic recovery, CORS headers. This composability means you build your server's request pipeline by stacking small, single-purpose functions rather than configuring a monolithic framework. Each middleware does one thing, is testable in isolation, and can be reordered or removed without touching handler logic.

Production HTTP servers must account for the hostile realities of the internet: slow clients, oversized payloads, connection exhaustion, and abrupt shutdowns. This means always setting read/write/idle timeouts on http.Server, limiting request body sizes with http.MaxBytesReader, implementing graceful shutdown to drain in-flight requests, and never using http.DefaultServeMux (a global mutable that any imported package can register routes on). These are not optimizations — they are the baseline for a server that survives contact with real traffic.

Anti-Patterns

  • Continuing execution after http.Error: Calling http.Error(w, "bad request", 400) does not return from the handler. If you forget to return afterward, the handler keeps running and may write conflicting data to the response. Every http.Error call should be immediately followed by return.

  • Using http.DefaultServeMux in production: The default mux is a package-level global. Any imported library can silently register routes on it (some debug packages do exactly this). Always create an explicit http.NewServeMux() or use a third-party router.

  • Omitting server timeouts: An http.Server with zero-value timeouts will hold connections open indefinitely for slow or malicious clients. Always set ReadTimeout, WriteTimeout, and IdleTimeout to sensible values based on your expected request/response sizes.

  • Passing dependencies through globals or context values: Storing your database connection in a global variable or stuffing it into context makes handlers hard to test and hides real dependencies. Use struct methods or closures to inject dependencies explicitly: func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request).

  • Writing headers after the body: Once you call w.Write(), Go implicitly sends a 200 status code and any headers already set. Calling w.WriteHeader() after w.Write() is a no-op that silently fails. Always set headers and status codes before writing the body.

Overview

Go's net/http package provides a production-grade HTTP server out of the box. The standard library gained improved routing with method and path-parameter support in Go 1.22. Third-party routers like chi and gin add convenience but are not strictly required for most applications.

Core Concepts

Handler Interface

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

type HandlerFunc func(ResponseWriter, *Request)

ServeMux (Go 1.22+)

Supports method matching and path parameters:

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

Middleware

A function that wraps a handler to add cross-cutting behavior (logging, auth, recovery).

Implementation Patterns

Standard Library Server (Go 1.22+)

func main() {
    mux := http.NewServeMux()

    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
    })

    mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
        id := r.PathValue("id")
        user, err := store.Get(r.Context(), id)
        if err != nil {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        json.NewEncoder(w).Encode(user)
    })

    mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
        var u User
        if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
            http.Error(w, "bad request", http.StatusBadRequest)
            return
        }
        // ...
        w.WriteHeader(http.StatusCreated)
        json.NewEncoder(w).Encode(u)
    })

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

Middleware Pattern

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 recovery(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", "err", err)
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// Chain middleware
handler := logging(recovery(mux))

Chi Router

import "github.com/go-chi/chi/v5"
import "github.com/go-chi/chi/v5/middleware"

func main() {
    r := chi.NewRouter()
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(30 * time.Second))

    r.Route("/api/v1", func(r chi.Router) {
        r.Route("/users", func(r chi.Router) {
            r.Get("/", listUsers)
            r.Post("/", createUser)
            r.Route("/{userID}", func(r chi.Router) {
                r.Use(userCtx)
                r.Get("/", getUser)
                r.Put("/", updateUser)
                r.Delete("/", deleteUser)
            })
        })
    })

    http.ListenAndServe(":8080", r)
}

func userCtx(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        userID := chi.URLParam(r, "userID")
        user, err := store.Get(r.Context(), userID)
        if err != nil {
            http.Error(w, "not found", http.StatusNotFound)
            return
        }
        ctx := context.WithValue(r.Context(), userKey, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

Gin Router

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default() // includes Logger and Recovery middleware

    v1 := r.Group("/api/v1")
    {
        users := v1.Group("/users")
        {
            users.GET("", listUsers)
            users.POST("", createUser)
            users.GET("/:id", getUser)
            users.PUT("/:id", updateUser)
            users.DELETE("/:id", deleteUser)
        }
    }

    r.Run(":8080")
}

func getUser(c *gin.Context) {
    id := c.Param("id")
    user, err := store.Get(c.Request.Context(), id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
        return
    }
    c.JSON(http.StatusOK, user)
}

Graceful Shutdown

func main() {
    srv := &http.Server{Addr: ":8080", Handler: mux}

    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("shutdown: %v", err)
    }
    log.Println("server exited")
}

JSON Response Helpers

func respondJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(data); err != nil {
        slog.Error("encoding response", "err", err)
    }
}

func respondError(w http.ResponseWriter, status int, msg string) {
    respondJSON(w, status, map[string]string{"error": msg})
}

Best Practices

  • Always set ReadTimeout, WriteTimeout, and IdleTimeout on http.Server.
  • Use graceful shutdown to drain in-flight requests before exiting.
  • Pass dependencies into handlers via closures or struct methods, not globals.
  • Use http.MaxBytesReader to limit request body size.
  • Set Content-Type headers explicitly on responses.
  • Use structured logging (slog) instead of log.Printf.
  • Return proper HTTP status codes: 201 for created, 204 for no content, 400/422 for bad input.

Common Pitfalls

  • Forgetting to return after http.Error: subsequent code still executes.
  • Not draining/closing request body: can leak connections in certain scenarios.
  • Using http.DefaultServeMux in production: it is a global and any imported package can register routes on it.
  • Writing headers after body: w.WriteHeader() must be called before w.Write(). Once you call w.Write(), a 200 status is sent implicitly.
  • Large JSON decoding without limits: use http.MaxBytesReader or json.Decoder with a size-limited reader.

Install this skill directly: skilldb add go-skills

Get CLI access →