Go's standard library has one of the better HTTP implementations of any language. For many services you do not need a framework — net/http is enough. When you do reach for help, the community has consolidated on a handful of lightweight, composable libraries.
The Smallest Useful Server
package main
import (
"log"
"net/http"
)
func main() {
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
That works for prototypes. For production, do not use this exact code — see "Production Server" below.
http.Handler and HandlerFunc
Everything in Go's HTTP world is a http.Handler:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
// HandlerFunc is an adapter so plain functions satisfy Handler:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }
The whole framework consists of: handlers, multiplexers (routers) that pick a handler from a URL, and middleware (handlers that wrap other handlers).
Routing with chi
The stdlib router (1.22+) is finally good enough for most cases — it supports path parameters and method matching. For teams wanting a touch more sugar (route groups, mounting), chi is the popular choice:
import "github.com/go-chi/chi/v5"
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Get("/healthz", health)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/users", listUsers)
r.Get("/users/{id}", getUser)
r.Post("/users", createUser)
r.Put("/users/{id}", updateUser)
r.Delete("/users/{id}", deleteUser)
})
func getUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
// ...
}
Avoid gin and echo for new projects unless you have a strong reason. Their non-standard handler signatures pull you out of the standard ecosystem (every middleware library, every observability tool, every test helper expects http.Handler).
Production Server with Timeouts and Graceful Shutdown
func main() {
r := chi.NewRouter()
// ... register routes
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1 MB
}
// Run in a goroutine so main can listen for signals
go func() {
log.Printf("listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server: %v", err)
}
}()
// Wait for SIGINT / SIGTERM
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("forced shutdown: %v", err)
}
}
srv.Shutdown closes the listener, waits for in-flight requests to finish (up to the context timeout), and then returns. Combined with preStop hooks and proper readiness probes, this gives you zero-downtime rolling updates in Kubernetes.
JSON Request and Response
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
type UserResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func createUser(w http.ResponseWriter, r *http.Request) {
var in CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
// ... create the user
user := UserResponse{ID: "u_1", Name: in.Name, Email: in.Email}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(user)
}
Limit request body size to protect against unbounded reads:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
Middleware
Middleware is a function that takes a handler and returns a handler:
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, 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 !isValid(token) {
http.Error(w, "unauthorised", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
r.Use(logRequests)
r.Group(func(r chi.Router) {
r.Use(requireAuth)
r.Get("/me", currentUser)
})
Common production middleware: request ID, structured logging, panic recovery, CORS, rate limiting, metrics (Prometheus), tracing (OpenTelemetry), compression, timeouts.
Structured Logging with slog
As of Go 1.21, structured logging is in the standard library:
import "log/slog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("user created", "user_id", id, "email", email)
slog.Error("db failure", "err", err, "query", "SELECT ...")
JSON output is ideal for shipping to Loki, Elasticsearch, CloudWatch, or any structured-logging backend. For more performance, use zap; for ergonomic API, zerolog.
HTTP Client
Never use http.DefaultClient in production — it has no timeout:
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
func fetchUser(ctx context.Context, id string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.example.com/users/"+id, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var u User
return &u, json.NewDecoder(resp.Body).Decode(&u)
}
Always: pass ctx, set a timeout, close the body, check the status code, limit retries with backoff (use cenkalti/backoff for non-trivial cases).
Observability
- Metrics —
prometheus/client_golang. Expose/metrics; a histogram per route is the standard pattern. - Tracing —
go.opentelemetry.io/otel. Instrument the HTTP server and client; export to Jaeger, Tempo, or any OTel backend. - Logging —
slogwith a request ID middleware so logs and traces correlate.
Wire all three on day one. Retrofitting an unobserved service after an incident is a rite of passage worth skipping.
What Else You Will Want
- validator for request payload validation
- pgx for Postgres (the modern driver — better than the older
database/sql+lib/pq) - sqlc if you want compile-time type-safe SQL
- golang-migrate for schema migrations
- go-redis for Redis
The Go HTTP ecosystem is small, focused, and remarkably consistent. Compose these pieces and you have a foundation that scales from a side project to a production microservice.