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
}
...
}| Pattern | Matches |
|---|---|
/v1/items | Exactly 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 /x | Both may be registered; the more specific method pattern wins |
- Registering two patterns that overlap and neither is more specific makes
NewServeMuxpanic 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
rdown 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 unboundedDecodeis a one-request denial of service. DisallowUnknownFieldsturns a typo in a JSON field into a clear 400 instead of a silently ignored value.- Use the
*_http.Requestcontext for cancellation and request-scoped values, and set response headers before the firstWrite, 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")- Stop accepting new connections and let in-flight requests finish, bounded by the shutdown context.
- Long-lived work started by a request must select on
r.Context().Done(), otherwise it keeps running past the shutdown deadline. - Close dependencies after
Shutdownreturns, in reverse order of construction: database pool, cache, message consumer. - 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.Related
Context, cancellation and concurrency patterns Testing, benchmarking and fuzzing
Last refreshed 2026-09-18.