Go Web Frameworks: net/http, Chi, Gin, Echo and Fiber · Building Production APIs in Go

Production Ready: Config, Logging, Timeouts and Graceful Shutdown

Turn a working API into a service you can operate: configuration from the environment, structured logs with log/slog, server timeouts, health checks, and graceful shutdown that lets in-flight requests finish.

Create a free account to save this page, take notes and track your progress. Sign in

What you'll build

The main function of a production Go API: reads config from environment variables, logs JSON with log/slog, sets server timeouts, exposes /healthz, and on SIGTERM stops accepting new requests while finishing the ones in progress.

The idea

  • Configuration from environment variables (twelve-factor style), with defaults and validation at startup: fail fast if something required is missing.
  • Structured logging with the standard log/slog package: JSON lines with fields (method, path, status, request_id) that log tools can search.
  • Timeouts on http.Server. The zero values mean no timeout, which lets slow or malicious clients hold connections forever. Set ReadHeaderTimeout, ReadTimeout, WriteTimeout and IdleTimeout.
  • Health checks: /healthz (the process is alive) and often /readyz (dependencies like the database are reachable) for load balancers and Kubernetes.
  • Graceful shutdown: on SIGINT/SIGTERM, call server.Shutdown(ctx). It stops accepting new connections, waits for active requests to finish (up to a deadline), then returns. Deployments then never cut requests off mid-way.

The code

slog, server timeouts and graceful shutdown with an in-flight request
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"os"
	"time"
)

type Config struct {
	Addr            string
	ShutdownTimeout time.Duration
}

func loadConfig(getenv func(string) string) (Config, error) {
	cfg := Config{Addr: "127.0.0.1:0", ShutdownTimeout: 10 * time.Second}
	if v := getenv("SHUTDOWN_TIMEOUT"); v != "" {
		d, err := time.ParseDuration(v)
		if err != nil {
			return cfg, fmt.Errorf("SHUTDOWN_TIMEOUT: %w", err)
		}
		cfg.ShutdownTimeout = d
	}
	return cfg, nil
}

func main() {
	// JSON logs; the time field is dropped here only so the output below is identical on every run.
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	}))

	if _, err := loadConfig(func(string) string { return "ten seconds" }); err != nil {
		logger.Error("bad config", "err", err)
	}
	cfg, _ := loadConfig(func(k string) string { return map[string]string{"SHUTDOWN_TIMEOUT": "2s"}[k] })

	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") })
	mux.HandleFunc("GET /report", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(300 * time.Millisecond) // a slow request that is in flight during shutdown
		io.WriteString(w, "report ready")
	})

	srv := &http.Server{
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
	}
	ln, _ := net.Listen("tcp", cfg.Addr)
	go srv.Serve(ln)
	base := "http://" + ln.Addr().String()
	logger.Info("listening", "shutdown_timeout", cfg.ShutdownTimeout.String())

	res, _ := http.Get(base + "/healthz")
	body, _ := io.ReadAll(res.Body)
	logger.Info("health check", "status", res.StatusCode, "body", string(body))

	done := make(chan string)
	go func() { // a client request that is still running when shutdown starts
		res, err := http.Get(base + "/report")
		if err != nil {
			done <- "error: " + err.Error()
			return
		}
		b, _ := io.ReadAll(res.Body)
		done <- fmt.Sprintf("%d %s", res.StatusCode, b)
	}()
	time.Sleep(100 * time.Millisecond)

	// In a real service this follows signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM).
	logger.Info("shutting down")
	ctx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
	defer cancel()
	if err := srv.Shutdown(ctx); err != nil {
		logger.Error("forced shutdown", "err", err)
	}
	logger.Info("in-flight request finished", "result", <-done)

	_, err := http.Get(base + "/healthz")
	var opErr *net.OpError
	logger.Info("new request after shutdown", "refused", errors.As(err, &opErr))
}

Output

{"level":"ERROR","msg":"bad config","err":"SHUTDOWN_TIMEOUT: time: invalid duration \"ten seconds\""}
{"level":"INFO","msg":"listening","shutdown_timeout":"2s"}
{"level":"INFO","msg":"health check","status":200,"body":"ok"}
{"level":"INFO","msg":"shutting down"}
{"level":"INFO","msg":"in-flight request finished","result":"200 report ready"}
{"level":"INFO","msg":"new request after shutdown","refused":true}

Run this on your computer — see “Get Started” for setup.

The slow /report request started before shutdown and still completed with 200; new connections after Shutdown are refused. That's what lets you deploy without dropping requests.

Common mistakes

  • http.ListenAndServe(addr, mux) in production. It uses a server with no timeouts; build an http.Server with timeouts.
  • Exiting on SIGTERM immediately, cutting off in-flight requests during every deploy.
  • Unstructured logs (log.Printf("user %s did %s")) that are hard to search; use slog fields.
  • Reading config all over the code with os.Getenv; load and validate it once at startup.
  • Health checks that hit every dependency and fail the whole fleet when one downstream is slow; keep liveness simple.

Key points

  • Config from the environment, validated at startup.
  • log/slog JSON logs with fields; include request IDs.
  • Always set http.Server timeouts.
  • server.Shutdown(ctx) on SIGTERM drains in-flight requests; add /healthz and /readyz.

Further reading: log/slog, http.Server.Shutdown, The twelve-factor app: config.

Practice questions

What does http.Server.Shutdown do?

Answer

It stops the server from accepting new connections, closes idle connections, and waits for active requests to finish until the context deadline expires, then returns. Call it on SIGTERM so deploys don't cut requests off.

Why is http.ListenAndServe with default settings risky in production?

Answer

The default server has no read, write or idle timeouts, so slow or malicious clients can hold connections and goroutines open indefinitely (e.g. slowloris). Configure an http.Server with ReadHeaderTimeout, ReadTimeout, WriteTimeout and IdleTimeout.

Open in the app to mark lessons complete, take notes and track your progress.

Was this page helpful?