Skip to content
8 min read·Lesson 7 of 8

HTTP Services, Clients, and Middleware

Build production HTTP services with the standard library and lightweight routers, structured logging, middleware, and graceful shutdown.

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

  • Metricsprometheus/client_golang. Expose /metrics; a histogram per route is the standard pattern.
  • Tracinggo.opentelemetry.io/otel. Instrument the HTTP server and client; export to Jaeger, Tempo, or any OTel backend.
  • Loggingslog with 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.

Key Takeaways

  • net/http in the stdlib is enough for many production services; chi adds routing sugar without a heavy framework.
  • http.Handler is just a function — middleware is a function that wraps a handler.
  • Always set ReadTimeout, WriteTimeout, and IdleTimeout — defaults are zero (no timeout).
  • Use context for cancellation; never start a goroutine without an exit path.
  • Graceful shutdown: signal handler + http.Server.Shutdown drains in-flight requests.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →