net/http standard library is production-ready. With Go 1.22+ enhanced routing, method and path pattern matching are built in. You do not need a framework for most services – but you do need to configure timeouts, graceful shutdown, and middleware correctly from the start.Most Go HTTP tutorials show http.ListenAndServe(":8080", nil) and call it a day. That code will run, but it will leak goroutines under load, hang forever on slow clients, and crash non-gracefully when you deploy a new version. This post covers what a production-ready Go HTTP service actually looks like, using only the standard library for the core, with a note on when a router library pays off.
The Basic Server with Proper Error Handling#
The first mistake to fix is ignoring the return value of ListenAndServe.
package main
import (
"errors"
"log/slog"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthHandler)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
slog.Info("starting server", "addr", srv.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "err", err)
}
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}http.ErrServerClosed is the sentinel error returned when you call server.Shutdown(). It is not a real error, so we filter it out. Any other non-nil return from ListenAndServe means the server failed to bind or had a fatal runtime error.
Go 1.22+ Pattern-Based Routing#
Before Go 1.22, the built-in ServeMux only matched path prefixes and had no method routing. Go 1.22 added method+path patterns and path parameters.
package main
import "net/http"
func registerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /users", listUsersHandler)
mux.HandleFunc("POST /users", createUserHandler)
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("PUT /users/{id}", updateUserHandler)
mux.HandleFunc("DELETE /users/{id}", deleteUserHandler)
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// id is the value captured from {id} in the pattern
w.Write([]byte("user id: " + id))
}The {id} syntax captures a single path segment. {path...} captures the remainder of the path (useful for file serving). Method prefixes like GET cause the mux to return 405 Method Not Allowed automatically when the path matches but the method does not.
Middleware: Logging and Request IDs#
Middleware in Go is just a function that takes an http.Handler and returns an http.Handler. No magic, no registration, no framework.
package main
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
)
type contextKey string
const requestIDKey contextKey = "requestID"
// RequestID injects a unique ID into every request context and response header.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
ctx := context.WithValue(r.Context(), requestIDKey, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Logger logs method, path, status code, and duration for every request.
func Logger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(rw, r)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rw.statusCode,
"duration_ms", time.Since(start).Milliseconds(),
"request_id", r.Context().Value(requestIDKey),
)
})
}
// responseWriter wraps http.ResponseWriter to capture the status code.
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Chain applies middleware in order: Chain(h, A, B, C) runs A(B(C(h))).
func Chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}Using Chain in main:
handler := Chain(mux, RequestID, Logger)
srv := &http.Server{Addr: ":8080", Handler: handler}Server Timeouts#
The default http.Server has no timeouts. A slow or malicious client can hold connections open indefinitely.
srv := &http.Server{
Addr: ":8080",
Handler: handler,
// Time to read the full request including body.
ReadTimeout: 5 * time.Second,
// Time to read request headers only. Catches slow header attacks.
// Should be less than or equal to ReadTimeout.
ReadHeaderTimeout: 2 * time.Second,
// Time to write the full response. Counted from end of request headers.
WriteTimeout: 10 * time.Second,
// Time an idle keep-alive connection is kept open.
IdleTimeout: 120 * time.Second,
}| Timeout | What it covers | Typical value |
|---|---|---|
ReadHeaderTimeout | Time to receive all request headers | 2-5s |
ReadTimeout | Time to receive full request including body | 5-30s |
WriteTimeout | Time to send full response | 5-30s |
IdleTimeout | Keep-alive idle connection lifetime | 60-120s |
For streaming responses or long-running uploads/downloads, WriteTimeout and ReadTimeout need to be longer or handled via per-request context deadlines. Setting them globally short will cut off legitimate long-running operations.
Graceful Shutdown#
When your container orchestrator sends SIGTERM, the process should stop accepting new connections, finish in-flight requests, and then exit cleanly.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
registerRoutes(mux)
handler := Chain(mux, RequestID, Logger)
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadHeaderTimeout: 2 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
// ctx is canceled when SIGINT or SIGTERM is received.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
slog.Info("starting server", "addr", srv.Addr)
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "err", err)
}
}()
// Block until signal.
<-ctx.Done()
slog.Info("shutdown signal received")
// Give in-flight requests up to 30 seconds to complete.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", "err", err)
}
slog.Info("server stopped")
}signal.NotifyContext is the idiomatic Go 1.16+ way to handle OS signals. The goroutine runs the server, the main goroutine waits for the signal, then calls Shutdown with a deadline.
Health Check Endpoint Pattern#
Health checks are the first thing a load balancer or Kubernetes liveness probe will call. Keep them cheap: no database queries, just a confirmation that the process is up.
package main
import (
"encoding/json"
"net/http"
"time"
)
type healthResponse struct {
Status string `json:"status"`
Timestamp string `json:"timestamp"`
}
func healthHandler(w http.ResponseWriter, r *http.Request) {
resp := healthResponse{
Status: "ok",
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}For a readiness probe (checks whether the service is ready to accept traffic, including dependencies), add a separate /ready endpoint that checks database connectivity or cache availability.
When to Reach for a Router Library#
The Go 1.22+ mux handles the majority of real-world routing needs. Consider a library like chi or gorilla/mux when:
- You have more than ~30 routes and want grouping with shared middleware per group.
- You need URL pattern matching beyond exact segments (e.g., regex constraints on path params).
- You want built-in support for route introspection or OpenAPI generation.
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUserHandler)
mux.HandleFunc("POST /users", createUserHandler)
// Apply middleware globally
handler := Chain(mux, RequestID, Logger)import "github.com/go-chi/chi/v5"
import "github.com/go-chi/chi/v5/middleware"
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Logger)
r.Route("/users", func(r chi.Router) {
r.Get("/{id}", getUserHandler)
r.Post("/", createUserHandler)
r.Group(func(r chi.Router) {
r.Use(authMiddleware) // only on this group
r.Delete("/{id}", deleteUserHandler)
})
})For a new microservice with a handful of endpoints, start with the stdlib. Migrate to chi if and when the routing logic grows complex enough to justify it.
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.