Net Http #

The net/http package is one of the most powerful packages in the Go standard library — it provides a production-ready HTTP client and server without external dependencies. A Go server can efficiently handle tens of thousands of concurrent connections because every request runs in its own goroutine, and net/http manages this goroutine pool automatically. On the client side, Go provides a complete http.Client with timeout, redirect, cookie, and TLS support. Understanding net/http well is the foundation of almost every Go application that interacts with the web — REST APIs, webhooks, web scrapers, proxies, and microservices are all built on top of it.

An Overview of the net/http Package #

flowchart TD
    HTTP["package net/http"] --> Server["HTTP Server"]
    HTTP --> Client["HTTP Client"]

    Server --> S1["http.ListenAndServe\non a specific port"]
    Server --> S2["http.ServeMux\nthe built-in router"]
    Server --> S3["http.Handler interface\n{ServeHTTP(w, r)}"]
    Server --> S4["http.HandlerFunc\nfunc(w, r) as a Handler"]
    Server --> S5["http.Server struct\nfull configuration"]

    Client --> C1["http.Get / http.Post\nsimple shortcuts"]
    Client --> C2["http.Client struct\ntimeout, redirect, cookies"]
    Client --> C3["http.NewRequest\nrequests with full control"]
    Client --> C4["http.Response\nstatus, headers, body"]

    Server --> MW["Middleware Pattern\nchain of handlers"]
    Client --> TR["http.Transport\nconnection pool, TLS, proxy"]

    style HTTP fill:#4f86c6,color:#fff
    style Server fill:#e8f5e9
    style Client fill:#e3f2fd
    style MW fill:#fff3e0
    style TR fill:#f3e5f5

HTTP Server — The Basics #

The simplest HTTP server in Go only needs a few lines:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    // Register a handler for a specific path
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello, World!")
    })

    http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintln(w, "pong")
    })

    // Start the server — blocks until an error
    fmt.Println("Server running on :8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Println("Server error:", err)
    }
}

nil as the second handler in ListenAndServe means “use http.DefaultServeMux” — the global router populated by http.HandleFunc. For production, always create your own ServeMux:

// ANTI-PATTERN: use the global DefaultServeMux
http.HandleFunc("/api/products", productHandler) // registers on the global mux

// CORRECT: create your own ServeMux — safer, easier to test
mux := http.NewServeMux()
mux.HandleFunc("/api/products", productHandler)
mux.HandleFunc("/api/users", userHandler)

http.ListenAndServe(":8080", mux)

The http.Handler Interface #

All handlers in Go implement one simple interface:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

This means any type with a ServeHTTP method can be used as a handler — structs, functions wrapped in http.HandlerFunc, or even middleware chains:

// Way 1: http.HandlerFunc — convert an ordinary function to a Handler
func helloHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello!")
}
mux.Handle("/hello", http.HandlerFunc(helloHandler))
// or more concisely:
mux.HandleFunc("/hello", helloHandler)

// Way 2: a struct implementing Handler
type ProductHandler struct {
    DB     *sql.DB
    Logger *log.Logger
}

func (h *ProductHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Can access h.DB and h.Logger
    switch r.Method {
    case http.MethodGet:
        h.listProducts(w, r)
    case http.MethodPost:
        h.createProduct(w, r)
    default:
        http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
    }
}

// Register the struct as a handler
productHandler := &ProductHandler{DB: db, Logger: logger}
mux.Handle("/api/products", productHandler)

ResponseWriter and Request #

http.ResponseWriter and *http.Request are the two parameters present in every handler. Understanding both well is the core of HTTP development in Go.

flowchart LR
    subgraph RW["http.ResponseWriter"]
        RW1["Header() http.Header\nset response headers"]
        RW2["WriteHeader(statusCode int)\nsend the status code"]
        RW3["Write([]byte) (int, error)\nsend the body"]
    end

    subgraph Req["*http.Request"]
        Req1["Method — GET, POST, etc."]
        Req2["URL — path, query params"]
        Req3["Header — request headers"]
        Req4["Body io.ReadCloser — the request body"]
        Req5["Context() — the request context"]
        Req6["Form / PostForm — parsed form"]
        Req7["RemoteAddr — the client IP"]
    end

    subgraph Order["Response Writing Order"]
        O1["1. Set Headers (before WriteHeader)"]
        O2["2. WriteHeader (status code)"]
        O3["3. Write (body)"]
        O1 --> O2 --> O3
    end

    style RW fill:#e8f5e9
    style Req fill:#e3f2fd
    style Order fill:#fff3e0
func exampleHandler(w http.ResponseWriter, r *http.Request) {
    // Read information from the Request
    fmt.Println("Method:", r.Method)
    fmt.Println("Path:", r.URL.Path)
    fmt.Println("Query:", r.URL.Query().Get("name"))
    fmt.Println("Header:", r.Header.Get("Authorization"))
    fmt.Println("Remote IP:", r.RemoteAddr)

    // IMPORTANT: Headers must be set BEFORE WriteHeader
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", "abc123")

    // WriteHeader must be called BEFORE Write
    // If not called, Write automatically calls WriteHeader(200)
    w.WriteHeader(http.StatusCreated) // 201

    // Write the body
    w.Write([]byte(`{"status":"success"}`))

    // ANTI-PATTERN: setting a header after WriteHeader — no effect!
    // w.Header().Set("X-Too-Late", "this value won't be sent")
}

Reading the Request Body #

func createProductHandler(w http.ResponseWriter, r *http.Request) {
    // Limit the body size to prevent attacks
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB
    defer r.Body.Close() // always close the body

    // Check the Content-Type
    contentType := r.Header.Get("Content-Type")
    if !strings.HasPrefix(contentType, "application/json") {
        http.Error(w, "Content-Type must be application/json",
            http.StatusUnsupportedMediaType)
        return
    }

    var product Product
    decoder := json.NewDecoder(r.Body)
    decoder.DisallowUnknownFields()
    if err := decoder.Decode(&product); err != nil {
        http.Error(w, "invalid body: "+err.Error(),
            http.StatusBadRequest)
        return
    }

    // Process and send the response
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated)
    json.NewEncoder(w).Encode(product)
}

Query Parameters and Paths #

func searchProductsHandler(w http.ResponseWriter, r *http.Request) {
    // Query parameters: /api/products?q=laptop&category=electronics&page=2
    query := r.URL.Query()

    word := query.Get("q")           // a string, empty if absent
    category := query.Get("category")
    pageStr := query.Get("page")

    page := 1
    if pageStr != "" {
        n, err := strconv.Atoi(pageStr)
        if err != nil || n < 1 {
            http.Error(w, "invalid 'page' parameter", http.StatusBadRequest)
            return
        }
        page = n
    }

    fmt.Fprintf(w, "Search: %q, Category: %q, Page: %d\n",
        word, category, page)
}

// Path parameters — Go 1.22+ supports the {id} pattern
// For older Go versions, parse manually or use an external router
mux.HandleFunc("/api/products/{id}", func(w http.ResponseWriter, r *http.Request) {
    // Go 1.22+
    id := r.PathValue("id")
    fmt.Fprintf(w, "Product ID: %s\n", id)
})
Go 1.22 introduced significant improvements to http.ServeMux: method matching (GET /api/products) and path parameters (/api/products/{id}). If you’re using Go 1.22+, these features reduce the need for an external router in many cases. For older Go versions, path parsing is done manually or with libraries like gorilla/mux or chi.

http.Server — Production Configuration #

http.ListenAndServe is a convenient shortcut but isn’t configured for production. For real applications, always use http.Server with explicit timeouts:

flowchart LR
    Client["HTTP Client"] --> Server["http.Server"]

    subgraph Timeouts["Important Timeouts"]
        T1["ReadTimeout\ntotal time to read a request"]
        T2["ReadHeaderTimeout\ntime to read headers only"]
        T3["WriteTimeout\ntime to write the response"]
        T4["IdleTimeout\nidle keep-alive connection time"]
    end

    Server --> Timeouts

    subgraph Risk["Without Timeouts"]
        R1["Slowloris attack\nclient sends headers very slowly"]
        R2["Resource exhaustion\ngoroutines pile up without limit"]
        R3["Memory leak\nconnections never closed"]
    end

    style Timeouts fill:#e8f5e9
    style Risk fill:#fce4ec
package main

import (
    "context"
    "fmt"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    mux := http.NewServeMux()
    registerRoutes(mux)

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,

        // Timeouts are mandatory for production
        ReadTimeout:       15 * time.Second,
        ReadHeaderTimeout: 5 * time.Second,
        WriteTimeout:      15 * time.Second,
        IdleTimeout:       60 * time.Second,

        // Header size limit
        MaxHeaderBytes: 1 << 20, // 1 MB
    }

    // Run the server in a separate goroutine
    go func() {
        fmt.Printf("Server running on %s\n", server.Addr)
        if err := server.ListenAndServe(); err != http.ErrServerClosed {
            fmt.Fprintf(os.Stderr, "server error: %v\n", err)
            os.Exit(1)
        }
    }()

    // Wait for a shutdown signal
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    <-sigChan

    fmt.Println("\nStarting graceful shutdown...")

    // Give 30 seconds for in-flight requests to finish
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := server.Shutdown(ctx); err != nil {
        fmt.Fprintf(os.Stderr, "shutdown error: %v\n", err)
    }
    fmt.Println("Server stopped")
}

func registerRoutes(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/products", listProductsHandler)
    mux.HandleFunc("POST /api/products", createProductHandler)
    mux.HandleFunc("GET /api/products/{id}", productDetailHandler)
    mux.HandleFunc("GET /health", healthHandler)
}

The Middleware Pattern #

Middleware is a function that wraps a handler — adding behavior before or after the original handler runs. This is a very common pattern for logging, authentication, CORS, rate limiting, and panic recovery.

flowchart LR
    Req["Request"] --> MW1["Middleware 1\nLogging"] --> MW2["Middleware 2\nAuth"] --> MW3["Middleware 3\nRateLimit"] --> H["Handler\n(business logic)"]
    H --> MW3b["Middleware 3\n(after)"] --> MW2b["Middleware 2\n(after)"] --> MW1b["Middleware 1\n(after)"] --> Resp["Response"]

    style MW1 fill:#e3f2fd
    style MW2 fill:#e8f5e9
    style MW3 fill:#fff3e0
    style H fill:#4f86c6,color:#fff
    style MW1b fill:#e3f2fd
    style MW2b fill:#e8f5e9
    style MW3b fill:#fff3e0
// Middleware type: a function that takes a Handler and returns a Handler
type Middleware func(http.Handler) http.Handler

// Logging middleware — record every request
func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

        // Wrap the ResponseWriter to capture the status code
        rw := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}

        next.ServeHTTP(rw, r)

        fmt.Printf("[%s] %s %s %d %v\n",
            start.Format("2006-01-02 15:04:05"),
            r.Method,
            r.URL.Path,
            rw.statusCode,
            time.Since(start),
        )
    })
}

// A ResponseWriter wrapper to capture the status code
type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

func (rw *responseWriter) WriteHeader(code int) {
    rw.statusCode = code
    rw.ResponseWriter.WriteHeader(code)
}

// Authentication middleware — check the Bearer token
func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if !strings.HasPrefix(authHeader, "Bearer ") {
            http.Error(w, "authentication required", http.StatusUnauthorized)
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")
        user, err := validateToken(token)
        if err != nil {
            http.Error(w, "invalid token", http.StatusUnauthorized)
            return
        }

        // Store the user in the context for the handler to access
        ctx := context.WithValue(r.Context(), keyUser, user)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Panic recovery middleware
func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                fmt.Fprintf(os.Stderr, "panic: %v\n", rec)
                http.Error(w, "internal server error",
                    http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

// Combine middleware — order from outside to inside
func chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

// Usage
mux := http.NewServeMux()
mux.HandleFunc("GET /api/products", listProductsHandler)

// Recovery → Logging → Auth → Handler
handler := chain(mux,
    recoveryMiddleware,
    loggingMiddleware,
    authMiddleware,
)

http.ListenAndServe(":8080", handler)

Storing and Reading Values in the Context #

// Define the key with a custom type to avoid collisions
type contextKey string

const (
    keyUser      contextKey = "user"
    keyRequestID contextKey = "request_id"
)

// Store in the middleware
ctx := context.WithValue(r.Context(), keyUser, user)
r = r.WithContext(ctx)

// Read in the handler
func profileHandler(w http.ResponseWriter, r *http.Request) {
    user, ok := r.Context().Value(keyUser).(*User)
    if !ok || user == nil {
        http.Error(w, "not authenticated", http.StatusUnauthorized)
        return
    }
    json.NewEncoder(w).Encode(user)
}

The HTTP Client #

Go provides http.Client for making HTTP requests to other servers. Don’t use http.DefaultClient in production because it has no timeout:

flowchart TD
    subgraph Anti["ANTI-PATTERN: http.DefaultClient"]
        A1["http.Get(url)\nor http.DefaultClient.Get(url)"]
        A2["No timeout!\nCan block forever"]
        A1 --> A2
    end

    subgraph Good["CORRECT: http.Client with a timeout"]
        G1["client := &http.Client{\n  Timeout: 30*time.Second\n}"]
        G2["client.Get(url)"]
        G3["Automatically cancels\nafter 30 seconds"]
        G1 --> G2 --> G3
    end

    style Anti fill:#fce4ec
    style Good fill:#e8f5e9

Creating an HTTP Client #

import (
    "net/http"
    "time"
)

// A client for production — always set a timeout
func makeHTTPClient() *http.Client {
    transport := &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        TLSHandshakeTimeout: 10 * time.Second,
    }

    return &http.Client{
        Timeout:   30 * time.Second,
        Transport: transport,
    }
}

// Use one client for the whole application (singleton)
var httpClient = makeHTTPClient()

GET Requests #

func fetchUserData(id int) (*User, error) {
    url := fmt.Sprintf("https://api.example.com/users/%d", id)

    resp, err := httpClient.Get(url)
    if err != nil {
        return nil, fmt.Errorf("fetchUserData: request failed: %w", err)
    }
    defer resp.Body.Close() // REQUIRED: always close the response body

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("fetchUserData: status %d from the server",
            resp.StatusCode)
    }

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("fetchUserData: decode response: %w", err)
    }

    return &user, nil
}

POST Requests with a JSON Body #

func sendProductData(product *Product) error {
    body, err := json.Marshal(product)
    if err != nil {
        return fmt.Errorf("sendProductData: marshal: %w", err)
    }

    req, err := http.NewRequest(
        http.MethodPost,
        "https://api.example.com/products",
        bytes.NewReader(body),
    )
    if err != nil {
        return fmt.Errorf("sendProductData: create request: %w", err)
    }

    // Set headers
    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("X-Request-ID", uuid.New().String())

    resp, err := httpClient.Do(req)
    if err != nil {
        return fmt.Errorf("sendProductData: send request: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusCreated {
        // Read the error body for more detail
        errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
        return fmt.Errorf("sendProductData: server returned %d: %s",
            resp.StatusCode, errBody)
    }

    return nil
}

Requests with Context — Timeout and Cancellation #

func fetchDataWithTimeout(ctx context.Context, url string) ([]byte, error) {
    // Create a sub-context with a specific timeout for this request
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("create request: %w", err)
    }

    resp, err := httpClient.Do(req)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            return nil, fmt.Errorf("request timed out after 10 seconds")
        }
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

    // Limit the response size read
    data, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20)) // 10 MB
    if err != nil {
        return nil, fmt.Errorf("read response: %w", err)
    }

    return data, nil
}

Handling Forms and File Uploads #

// An ordinary HTML form (application/x-www-form-urlencoded)
func submitFormHandler(w http.ResponseWriter, r *http.Request) {
    // ParseForm must be called before accessing r.Form or r.FormValue
    if err := r.ParseForm(); err != nil {
        http.Error(w, "failed to parse form", http.StatusBadRequest)
        return
    }

    name := r.FormValue("name")         // a shortcut for r.Form.Get("name")
    email := r.FormValue("email")
    ageStr := r.FormValue("age")

    age, err := strconv.Atoi(ageStr)
    if err != nil {
        http.Error(w, "invalid age", http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "Received: %s (%s), age %d\n", name, email, age)
}

// Multipart forms — for file uploads
func uploadPhotoHandler(w http.ResponseWriter, r *http.Request) {
    // Limit the size: 10 MB for the file, 32 MB total
    if err := r.ParseMultipartForm(32 << 20); err != nil {
        http.Error(w, "failed to parse multipart form", http.StatusBadRequest)
        return
    }

    // Get the plain text field
    name := r.FormValue("name")

    // Get the file
    file, header, err := r.FormFile("photo")
    if err != nil {
        http.Error(w, "photo file not found", http.StatusBadRequest)
        return
    }
    defer file.Close()

    // Validate the file type
    buffer := make([]byte, 512)
    _, err = file.Read(buffer)
    if err != nil {
        http.Error(w, "failed to read the file", http.StatusBadRequest)
        return
    }
    contentType := http.DetectContentType(buffer)
    if !strings.HasPrefix(contentType, "image/") {
        http.Error(w, "only image files are accepted",
            http.StatusUnsupportedMediaType)
        return
    }

    // Reset the read position to the start after content type detection
    file.Seek(0, 0)

    // Save the file
    fileName := fmt.Sprintf("upload/%s_%s", name, header.Filename)
    dst, err := os.Create(fileName)
    if err != nil {
        http.Error(w, "failed to save the file", http.StatusInternalServerError)
        return
    }
    defer dst.Close()

    bytesCopied, err := io.Copy(dst, file)
    if err != nil {
        http.Error(w, "failed to save the file", http.StatusInternalServerError)
        return
    }

    fmt.Fprintf(w, "File %s (%d bytes) uploaded successfully\n",
        header.Filename, bytesCopied)
}

Serving Static Files #

// Serve a static file directory
mux.Handle("/static/",
    http.StripPrefix("/static/",
        http.FileServer(http.Dir("./assets"))))

// Serve a single file
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "./assets/favicon.ico")
})

// Embed files into the binary (Go 1.16+)
import "embed"

//go:embed assets/*
var assets embed.FS

mux.Handle("/static/",
    http.StripPrefix("/static/",
        http.FileServer(http.FS(assets))))

Production Usage Patterns #

A Complete REST API Handler #

type ProductService interface {
    List(ctx context.Context, filter Filter) ([]Product, error)
    Find(ctx context.Context, id int) (*Product, error)
    Create(ctx context.Context, input ProductInput) (*Product, error)
    Update(ctx context.Context, id int, input ProductInput) (*Product, error)
    Delete(ctx context.Context, id int) error
}

type ProductHandlerV2 struct {
    Service ProductService
}

func (h *ProductHandlerV2) ListProducts(w http.ResponseWriter, r *http.Request) {
    products, err := h.Service.List(r.Context(), Filter{})
    if err != nil {
        sendErrorJSON(w, http.StatusInternalServerError, "failed to load products")
        return
    }
    sendJSON(w, http.StatusOK, products)
}

func (h *ProductHandlerV2) ProductDetail(w http.ResponseWriter, r *http.Request) {
    idStr := r.PathValue("id") // Go 1.22+
    id, err := strconv.Atoi(idStr)
    if err != nil {
        sendErrorJSON(w, http.StatusBadRequest, "invalid ID")
        return
    }

    product, err := h.Service.Find(r.Context(), id)
    if err != nil {
        if errors.Is(err, ErrNotFound) {
            sendErrorJSON(w, http.StatusNotFound, "product not found")
            return
        }
        sendErrorJSON(w, http.StatusInternalServerError, "failed to load product")
        return
    }

    sendJSON(w, http.StatusOK, product)
}

func (h *ProductHandlerV2) CreateProduct(w http.ResponseWriter, r *http.Request) {
    var input ProductInput
    if err := decodeJSON(w, r, &input); err != nil {
        sendErrorJSON(w, http.StatusBadRequest, err.Error())
        return
    }

    product, err := h.Service.Create(r.Context(), input)
    if err != nil {
        var errVal *ValidationError
        if errors.As(err, &errVal) {
            sendErrorJSON(w, http.StatusBadRequest, errVal.Message)
            return
        }
        sendErrorJSON(w, http.StatusInternalServerError, "failed to create product")
        return
    }

    sendJSON(w, http.StatusCreated, product)
}

// Helper functions
func sendJSON(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func sendErrorJSON(w http.ResponseWriter, status int, message string) {
    sendJSON(w, status, map[string]string{"error": message})
}

func decodeJSON(w http.ResponseWriter, r *http.Request, target any) error {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields()
    return dec.Decode(target)
}

// Route registration
func (h *ProductHandlerV2) RegisterRoutes(mux *http.ServeMux) {
    mux.HandleFunc("GET /api/v1/products", h.ListProducts)
    mux.HandleFunc("POST /api/v1/products", h.CreateProduct)
    mux.HandleFunc("GET /api/v1/products/{id}", h.ProductDetail)
}

An HTTP Client with Retry #

import (
    "context"
    "math"
    "net/http"
    "time"
)

type RetryClient struct {
    client    *http.Client
    maxRetry  int
    baseDelay time.Duration
}

func (rc *RetryClient) Do(req *http.Request) (*http.Response, error) {
    var lastErr error

    for attempt := 0; attempt <= rc.maxRetry; attempt++ {
        if attempt > 0 {
            // Exponential backoff: 1s, 2s, 4s, 8s, ...
            delay := time.Duration(math.Pow(2, float64(attempt-1))) *
                rc.baseDelay
            select {
            case <-time.After(delay):
            case <-req.Context().Done():
                return nil, req.Context().Err()
            }
        }

        // Clone the request for retries (the body was already read on the first attempt)
        resp, err := rc.client.Do(req)
        if err != nil {
            lastErr = err
            // Retry only for network errors, not application errors
            continue
        }

        // Retry for 5xx server errors
        if resp.StatusCode >= 500 {
            resp.Body.Close()
            lastErr = fmt.Errorf("server error: %d", resp.StatusCode)
            continue
        }

        return resp, nil
    }

    return nil, fmt.Errorf("failed after %d retries: %w", rc.maxRetry, lastErr)
}

A Health Check Handler #

type HealthStatus struct {
    Status    string            `json:"status"`
    Time      time.Time         `json:"time"`
    Version   string            `json:"version"`
    Components map[string]string `json:"components"`
}

func healthHandler(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        status := HealthStatus{
            Time:       time.Now(),
            Version:    "1.0.0",
            Components: make(map[string]string),
        }

        // Check the database connection
        ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
        defer cancel()

        if err := db.PingContext(ctx); err != nil {
            status.Components["database"] = "down: " + err.Error()
            status.Status = "degraded"
        } else {
            status.Components["database"] = "up"
            status.Status = "ok"
        }

        httpStatus := http.StatusOK
        if status.Status != "ok" {
            httpStatus = http.StatusServiceUnavailable
        }

        sendJSON(w, httpStatus, status)
    }
}

When to Switch to Alternatives #

Keep using net/http if:
  ✓ HTTP servers and clients for all common needs
  ✓ REST APIs with simple routing (especially Go 1.22+)
  ✓ HTTP clients for calling external APIs
  ✓ Serving static files
  ✓ Middleware chains for logging, auth, recovery

Consider an external router if:
  ✗ Go < 1.22 and you need path parameters (/api/products/:id)
  ✗ Route grouping with prefixes and per-group middleware
  ✗ Named routes and URL generation
  → chi, gorilla/mux, httprouter, echo, gin

Consider a framework if:
  ✗ You need a complete ecosystem: ORM, validation, templates, auth
  ✗ The team is more comfortable with framework conventions
  → echo, gin, fiber (fasthttp-based, not net/http)

Consider gRPC if:
  ✗ Inter-microservice communication needing high performance
  ✗ Strict API contracts with Protocol Buffers
  ✗ Bidirectional streaming
  → google.golang.org/grpc

Summary #

  • Always use http.Server with explicit timeouts in production — ReadTimeout, WriteTimeout, and IdleTimeout protect the server from Slowloris attacks and resource exhaustion.
  • Create your own http.ServeMux, don’t use the global http.DefaultServeMux — safer, testable, and no potential conflicts with other packages.
  • The response writing order must be followed: Header().Set()WriteHeader()Write(). Headers set after WriteHeader won’t be sent.
  • Always defer resp.Body.Close() after a successful HTTP client request — unclosed bodies prevent connections from returning to the pool and cause goroutine leaks.
  • Don’t use http.DefaultClient in production because it has no timeout — create an http.Client with an explicit Timeout.
  • Middleware runs outside to inside — the Recovery → Logging → Auth → Handler order ensures every request is logged and panics are recovered, even before authentication is checked.
  • Use context for request timeouts with http.NewRequestWithContext — a cancelled context automatically cancels the in-flight HTTP request.
  • http.MaxBytesReader is mandatory for limiting request body sizes — without it, clients can send unlimited bodies and drain server memory.
  • Go 1.22+: use method matching (GET /api/products) and path parameters (/api/products/{id}) directly in ServeMux — often enough without an external router.
  • Health check endpoints (/health or /healthz) are the standard for container deployments — Kubernetes and load balancers use them for traffic routing.

← Previous: Encoding Json   Next: Context →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact