Building HTTP services

Handlers and the enhanced ServeMux routing patterns, middleware chains, JSON encoding, request limits and timeouts, graceful shutdown, and context propagation.

Handlers and routing patterns

mux := http.NewServeMux()

// method + path pattern, with wildcards since Go 1.22
mux.HandleFunc("GET /v1/items", listItems)
mux.HandleFunc("POST /v1/items", createItem)
mux.HandleFunc("GET /v1/items/{id}", getItem)
mux.HandleFunc("DELETE /v1/items/{id}", deleteItem)
mux.HandleFunc("GET /static/", serveStatic)     // trailing slash: subtree match
mux.HandleFunc("/", notFound)                    // catch-all, lowest precedence

func getItem(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")          // wildcard value, already URL-decoded
    if id == "" {
        http.Error(w, "missing id", http.StatusBadRequest)
        return
    }
    ...
}
PatternMatches
/v1/itemsExactly that path; a request to /v1/items/ redirects
/v1/items/The subtree below it, including the path itself
GET /v1/items/{id}One path segment, captured as id
/files/{path...}The rest of the path, including slashes
POST /x and /xBoth may be registered; the more specific method pattern wins
  • Registering two patterns that overlap and neither is more specific makes NewServeMux panic at startup. That is a feature: routing conflicts appear during the first run, not in production.
  • Wildcard values are available only from the request matched by the pattern, so pass r down rather than extracting a dozen strings.
  • The standard mux is enough for most services. Reach for a third-party router when you need regex constraints, named routes or automatic unpacking into structs.

Middleware, JSON and limits

type Middleware func(http.Handler) http.Handler

func withLogging(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,
            "dur", time.Since(start))
    })
}

func withRecovery(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                slog.Error("panic", "err", rec, "path", r.URL.Path)
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// chain: the last wrapper runs first
handler := withLogging(withRecovery(mux))

func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) error {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // 1 MiB ceiling
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    if err := dec.Decode(dst); err != nil {
        return fmt.Errorf("invalid body: %w", err)
    }
    return nil
}
  • Middleware is just a function from handler to handler, so composition needs no framework and the type signature is the documentation.
  • Always cap the request body with http.MaxBytesReader; an unbounded Decode is a one-request denial of service.
  • DisallowUnknownFields turns a typo in a JSON field into a clear 400 instead of a silently ignored value.
  • Use the *_http.Request context for cancellation and request-scoped values, and set response headers before the first Write, because that call flushes the status line.

Timeouts and graceful shutdown

srv := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       10 * time.Second,
    WriteTimeout:      15 * time.Second,
    IdleTimeout:       60 * time.Second,
}

ctx, stop := signal.NotifyContext(context.Background(),
    os.Interrupt, syscall.SIGTERM)
defer stop()

go func() {
    if err := srv.ListenAndServe(); err != nil &&
        !errors.Is(err, http.ErrServerClosed) {
        slog.Error("listen failed", "err", err)
        os.Exit(1)
    }
}()

<-ctx.Done()                        // signal received
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
    slog.Error("forced close", "err", err)
}
slog.Info("stopped cleanly")
  1. Stop accepting new connections and let in-flight requests finish, bounded by the shutdown context.
  2. Long-lived work started by a request must select on r.Context().Done(), otherwise it keeps running past the shutdown deadline.
  3. Close dependencies after Shutdown returns, in reverse order of construction: database pool, cache, message consumer.
  4. Return a non-zero exit code only when shutdown failed; a clean stop under SIGTERM is normal.
💡
Context is the request's deadline in code form. Pass r.Context() into every database call, HTTP client call and goroutine you spawn for that request, and the whole tree cancels when the client disconnects or the server stops.

FAQ

Do I need a web framework?
For most JSON services, no. The standard mux covers method and wildcard routing, middleware is a three-line function type, and encoding/json handles the payload. Add a framework when you need validation, dependency injection or OpenAPI generation out of the box.
Why does Shutdown hang until the deadline?
Something ignored cancellation: a handler blocked on a channel, a goroutine still holding the request context, or a connection that was hijacked. Select on ctx.Done() in the blocking call and the shutdown becomes immediate.

Context, cancellation and concurrency patterns Testing, benchmarking and fuzzing

Last refreshed 2026-09-18.