Skip to content
Go back

Mastering Go's net/http Package - Building Production-Ready HTTP Services

Go’s net/http package is one of the most well-designed HTTP implementations in any standard library. After building production systems serving millions of requests daily, I can confidently say that you rarely need a framework - the standard library gives you everything you need.

Let’s explore what makes net/http powerful and how to leverage it effectively.

Table of Contents

Open Table of Contents

The Basics: Your First HTTP Server

The simplest HTTP server in Go is remarkably straightforward:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, you've requested: %s\n", r.URL.Path)
    })

    http.ListenAndServe(":8080", nil)
}

This works, but it’s not production-ready. Let’s build on this foundation.

The http.Server Type: Taking Control

Always use http.Server explicitly rather than the package-level ListenAndServe. This gives you control over timeouts and graceful shutdowns:

package main

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", healthCheckHandler)
    mux.HandleFunc("/api/users", usersHandler)

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

    // Start server in a goroutine
    go func() {
        log.Printf("Starting server on %s", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server failed to start: %v", err)
        }
    }()

    // Graceful shutdown
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    log.Println("Shutting down server...")
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }

    log.Println("Server exited")
}

func healthCheckHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func usersHandler(w http.ResponseWriter, r *http.Request) {
    // Handler implementation
}

Why this matters:

Request Handling Patterns

The http.Handler Interface

Everything in net/http revolves around this simple interface:

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

You can implement handlers as functions or types:

// Function-based handler
func myHandler(w http.ResponseWriter, r *http.Request) {
    // Handle request
}

// Type-based handler
type APIHandler struct {
    db *sql.DB
}

func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // Handle request with access to h.db
}

Method Routing

The standard library doesn’t provide method-based routing out of the box, but it’s trivial to implement:

func usersHandler(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        getUsers(w, r)
    case http.MethodPost:
        createUser(w, r)
    case http.MethodPut:
        updateUser(w, r)
    case http.MethodDelete:
        deleteUser(w, r)
    default:
        w.Header().Set("Allow", "GET, POST, PUT, DELETE")
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

URL Parameters and Query Strings

func productHandler(w http.ResponseWriter, r *http.Request) {
    // Parse URL path
    productID := strings.TrimPrefix(r.URL.Path, "/api/products/")

    // Parse query parameters
    query := r.URL.Query()
    sortBy := query.Get("sort")      // Single value
    tags := query["tag"]             // Multiple values
    limit := query.Get("limit")

    // Convert and validate
    limitInt := 10
    if limit != "" {
        if l, err := strconv.Atoi(limit); err == nil && l > 0 {
            limitInt = l
        }
    }

    // Use the parsed values
    log.Printf("Product ID: %s, Sort: %s, Tags: %v, Limit: %d",
        productID, sortBy, tags, limitInt)
}

Request Body Handling

Reading JSON

type CreateUserRequest struct {
    Name  string `json:"name"`
    Email string `json:"email"`
}

func createUser(w http.ResponseWriter, r *http.Request) {
    // Limit request body size to prevent abuse
    r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB

    var req CreateUserRequest
    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // Strict parsing

    if err := dec.Decode(&req); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    // Validate
    if req.Name == "" || req.Email == "" {
        http.Error(w, "Name and email are required", http.StatusBadRequest)
        return
    }

    // Process the request
    // ...

    w.WriteHeader(http.StatusCreated)
}

Reading Form Data

func uploadHandler(w http.ResponseWriter, r *http.Request) {
    // Parse multipart form (32MB max memory)
    if err := r.ParseMultipartForm(32 << 20); err != nil {
        http.Error(w, "Failed to parse form", http.StatusBadRequest)
        return
    }

    // Get form values
    title := r.FormValue("title")
    description := r.FormValue("description")

    // Get uploaded file
    file, header, err := r.FormFile("document")
    if err != nil {
        http.Error(w, "Failed to get file", http.StatusBadRequest)
        return
    }
    defer file.Close()

    log.Printf("Uploaded file: %s, size: %d bytes",
        header.Filename, header.Size)

    // Process the file
    // ...
}

Response Writing

Setting Headers and Status Codes

func jsonResponse(w http.ResponseWriter, data interface{}, status int) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)

    if err := json.NewEncoder(w).Encode(data); err != nil {
        log.Printf("Failed to encode JSON: %v", err)
    }
}

func userHandler(w http.ResponseWriter, r *http.Request) {
    user := User{ID: 1, Name: "John Doe"}
    jsonResponse(w, user, http.StatusOK)
}

Important: Call WriteHeader() before writing the response body. Once you write to the body, the status code is locked to 200.

Streaming Responses

func streamHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    flusher, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "Streaming not supported", http.StatusInternalServerError)
        return
    }

    // Stream data
    for i := 0; i < 10; i++ {
        fmt.Fprintf(w, "data: Message %d\n\n", i)
        flusher.Flush()
        time.Sleep(time.Second)

        // Check if client disconnected
        if r.Context().Err() != nil {
            log.Println("Client disconnected")
            return
        }
    }
}

Middleware: The Swiss Army Knife

Middleware is a function that wraps an http.Handler and returns a new http.Handler:

type Middleware func(http.Handler) http.Handler

Logging Middleware

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()

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

        next.ServeHTTP(wrapped, r)

        log.Printf("%s %s %d %s",
            r.Method,
            r.URL.Path,
            wrapped.statusCode,
            time.Since(start),
        )
    })
}

type responseWriter struct {
    http.ResponseWriter
    statusCode int
}

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

Authentication Middleware

func authMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if token == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        // Validate token and extract user info
        userID, err := validateToken(token)
        if err != nil {
            http.Error(w, "Invalid token", http.StatusUnauthorized)
            return
        }

        // Add user ID to context
        ctx := context.WithValue(r.Context(), "userID", userID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func validateToken(token string) (string, error) {
    // Token validation logic
    return "user123", nil
}

Recovery Middleware

func recoveryMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("Panic recovered: %v\n%s", err, debug.Stack())
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

Chaining Middleware

func chain(h http.Handler, middleware ...Middleware) http.Handler {
    for i := len(middleware) - 1; i >= 0; i-- {
        h = middleware[i](h)
    }
    return h
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/users", usersHandler)

    handler := chain(mux,
        recoveryMiddleware,
        loggingMiddleware,
        authMiddleware,
    )

    srv := &http.Server{
        Addr:    ":8080",
        Handler: handler,
    }

    log.Fatal(srv.ListenAndServe())
}

Context: Request-Scoped Values and Cancellation

Every http.Request has a context that’s cancelled when the client disconnects:

func longRunningHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()

    // Simulate long-running operation
    result := make(chan string, 1)
    go func() {
        time.Sleep(5 * time.Second)
        result <- "Operation completed"
    }()

    select {
    case <-ctx.Done():
        // Client disconnected
        log.Println("Client disconnected, cancelling operation")
        return
    case res := <-result:
        fmt.Fprint(w, res)
    }
}

Passing Values Through Context

type contextKey string

const userIDKey contextKey = "userID"

func getUserID(ctx context.Context) (string, bool) {
    userID, ok := ctx.Value(userIDKey).(string)
    return userID, ok
}

func protectedHandler(w http.ResponseWriter, r *http.Request) {
    userID, ok := getUserID(r.Context())
    if !ok {
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    fmt.Fprintf(w, "User ID: %s", userID)
}

The http.Client: Making Requests

Basic Client Usage

func fetchUser(userID string) (*User, error) {
    resp, err := http.Get(fmt.Sprintf("https://api.example.com/users/%s", userID))
    if err != nil {
        return nil, fmt.Errorf("request failed: %w", err)
    }
    defer resp.Body.Close()

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

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

    return &user, nil
}

Configured Client with Timeouts

var httpClient = &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        DisableKeepAlives:   false,
    },
}

func makeAPICall(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }

    req.Header.Set("User-Agent", "MyApp/1.0")
    req.Header.Set("Accept", "application/json")

    resp, err := httpClient.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    return io.ReadAll(resp.Body)
}

Posting JSON Data

func createResource(ctx context.Context, data interface{}) error {
    payload, err := json.Marshal(data)
    if err != nil {
        return err
    }

    req, err := http.NewRequestWithContext(
        ctx,
        http.MethodPost,
        "https://api.example.com/resources",
        bytes.NewBuffer(payload),
    )
    if err != nil {
        return err
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("Authorization", "Bearer token123")

    resp, err := httpClient.Do(req)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusCreated {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("request failed: %d - %s", resp.StatusCode, body)
    }

    return nil
}

Testing HTTP Handlers

Using httptest

func TestUserHandler(t *testing.T) {
    req := httptest.NewRequest(http.MethodGet, "/api/users/123", nil)
    w := httptest.NewRecorder()

    userHandler(w, req)

    resp := w.Result()
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        t.Errorf("expected status 200, got %d", resp.StatusCode)
    }

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        t.Fatalf("failed to decode response: %v", err)
    }

    if user.ID != "123" {
        t.Errorf("expected user ID 123, got %s", user.ID)
    }
}

Testing Middleware

func TestLoggingMiddleware(t *testing.T) {
    handler := loggingMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    }))

    req := httptest.NewRequest(http.MethodGet, "/test", nil)
    w := httptest.NewRecorder()

    handler.ServeHTTP(w, req)

    if w.Code != http.StatusOK {
        t.Errorf("expected status 200, got %d", w.Code)
    }
}

Mock HTTP Server

func TestAPIClient(t *testing.T) {
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path != "/api/data" {
            t.Errorf("unexpected path: %s", r.URL.Path)
        }

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    }))
    defer server.Close()

    // Use server.URL in your client tests
    resp, err := http.Get(server.URL + "/api/data")
    if err != nil {
        t.Fatalf("request failed: %v", err)
    }
    defer resp.Body.Close()

    // Assert response
}

Production Patterns

Rate Limiting

import "golang.org/x/time/rate"

func rateLimitMiddleware(limiter *rate.Limiter) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !limiter.Allow() {
                http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

// Usage
limiter := rate.NewLimiter(rate.Limit(100), 200) // 100 req/sec, burst of 200
handler := rateLimitMiddleware(limiter)(mux)

Request ID Tracing

func requestIDMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := r.Header.Get("X-Request-ID")
        if requestID == "" {
            requestID = generateRequestID()
        }

        w.Header().Set("X-Request-ID", requestID)
        ctx := context.WithValue(r.Context(), "requestID", requestID)

        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

func generateRequestID() string {
    b := make([]byte, 16)
    rand.Read(b)
    return fmt.Sprintf("%x", b)
}

Health Checks

type HealthChecker struct {
    db *sql.DB
}

func (h *HealthChecker) Check(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    checks := map[string]string{
        "database": "ok",
        "cache":    "ok",
    }

    // Check database
    if err := h.db.PingContext(ctx); err != nil {
        checks["database"] = err.Error()
        w.WriteHeader(http.StatusServiceUnavailable)
    }

    // Check other dependencies...

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(checks)
}

HTTPS and TLS

Basic HTTPS Server

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", handler)

    srv := &http.Server{
        Addr:    ":443",
        Handler: mux,
    }

    log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))
}

Custom TLS Configuration

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", handler)

    srv := &http.Server{
        Addr:    ":443",
        Handler: mux,
        TLSConfig: &tls.Config{
            MinVersion:               tls.VersionTLS12,
            CurvePreferences:         []tls.CurveID{tls.CurveP521, tls.CurveP384, tls.CurveP256},
            PreferServerCipherSuites: true,
            CipherSuites: []uint16{
                tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
                tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
                tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
            },
        },
    }

    log.Fatal(srv.ListenAndServeTLS("cert.pem", "key.pem"))
}

Common Pitfalls and Solutions

1. Not Closing Response Bodies

// Bad
resp, _ := http.Get(url)
// Response body never closed - memory leak!

// Good
resp, err := http.Get(url)
if err != nil {
    return err
}
defer resp.Body.Close()

2. Not Setting Timeouts

// Bad - no timeout, can hang forever
client := &http.Client{}

// Good - always set timeouts
client := &http.Client{
    Timeout: 10 * time.Second,
}

3. Writing Status After Body

// Bad - status is locked to 200 after first write
w.Write([]byte("data"))
w.WriteHeader(http.StatusCreated) // Too late!

// Good
w.WriteHeader(http.StatusCreated)
w.Write([]byte("data"))

4. Ignoring Context Cancellation

// Bad - doesn't respect client disconnect
func handler(w http.ResponseWriter, r *http.Request) {
    time.Sleep(10 * time.Second)
    w.Write([]byte("done"))
}

// Good
func handler(w http.ResponseWriter, r *http.Request) {
    timer := time.NewTimer(10 * time.Second)
    defer timer.Stop()

    select {
    case <-timer.C:
        w.Write([]byte("done"))
    case <-r.Context().Done():
        log.Println("client disconnected")
        return
    }
}

Performance Considerations

Connection Pooling

The default http.Transport pools connections efficiently:

var defaultTransport = &http.Transport{
    MaxIdleConns:        100,              // Total idle connections
    MaxIdleConnsPerHost: 10,               // Idle per host
    IdleConnTimeout:     90 * time.Second, // How long idle connections stay open
}

Reuse Clients

Don’t create a new http.Client for each request:

// Bad - creates new client every time
func makeRequest(url string) {
    client := &http.Client{}
    client.Get(url)
}

// Good - reuse client
var httpClient = &http.Client{
    Timeout: 10 * time.Second,
}

func makeRequest(url string) {
    httpClient.Get(url)
}

Streaming Large Responses

func downloadLargeFile(url string) error {
    resp, err := http.Get(url)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    out, err := os.Create("output.bin")
    if err != nil {
        return err
    }
    defer out.Close()

    // Stream directly to file without loading into memory
    _, err = io.Copy(out, resp.Body)
    return err
}

Final Thoughts

Go’s net/http package gives you the building blocks for production-grade HTTP services without the overhead of a framework. The patterns shown here—proper timeouts, graceful shutdowns, middleware composition, context usage, and thorough testing—form the foundation of reliable HTTP services.

The beauty of net/http is its simplicity and composability. You’re not fighting abstractions; you’re working directly with the HTTP protocol. This directness makes debugging easier, performance more predictable, and your code more maintainable.

Start simple, add complexity only when needed, and always remember: the standard library is usually enough.


Want to dive deeper? Check out the official net/http documentation and Russ Cox’s excellent writings on Go’s HTTP implementation.


Share this post on:

Previous Post
UV for Python - Fast Package Management
Next Post
Creating an Audio Analyzer with Qdrant Vector Database