HTTP Servers
HTTP server patterns using net/http, chi, and gin including middleware, routing, and graceful shutdown
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 linesHTTP 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: Callinghttp.Error(w, "bad request", 400)does not return from the handler. If you forget toreturnafterward, the handler keeps running and may write conflicting data to the response. Everyhttp.Errorcall should be immediately followed byreturn. -
Using
http.DefaultServeMuxin 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 explicithttp.NewServeMux()or use a third-party router. -
Omitting server timeouts: An
http.Serverwith zero-value timeouts will hold connections open indefinitely for slow or malicious clients. Always setReadTimeout,WriteTimeout, andIdleTimeoutto 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. Callingw.WriteHeader()afterw.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, andIdleTimeoutonhttp.Server. - Use graceful shutdown to drain in-flight requests before exiting.
- Pass dependencies into handlers via closures or struct methods, not globals.
- Use
http.MaxBytesReaderto limit request body size. - Set
Content-Typeheaders explicitly on responses. - Use structured logging (
slog) instead oflog.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.DefaultServeMuxin production: it is a global and any imported package can register routes on it. - Writing headers after body:
w.WriteHeader()must be called beforew.Write(). Once you callw.Write(), a 200 status is sent implicitly. - Large JSON decoding without limits: use
http.MaxBytesReaderorjson.Decoderwith a size-limited reader.
Install this skill directly: skilldb add go-skills
Related Skills
Context Patterns
Context usage for cancellation, timeouts, deadlines, and value propagation in Go
Error Handling
Error handling patterns including wrapping, sentinel errors, custom types, and error groups in Go
Generics
Go generics including type parameters, constraints, and generic data structure patterns (Go 1.18+)
Goroutines Channels
Concurrency patterns using goroutines, channels, select, and sync primitives in Go
Interfaces
Interface design principles, implicit satisfaction, and composition patterns in Go
Modules
Go modules, dependency management, versioning, workspaces, and private module configuration