Go net/http: Building HTTP Servers

• Go's `net/http` package is production-ready out of the box, offering everything needed to build robust HTTP servers without external dependencies

Key Insights

• Go’s net/http package is production-ready out of the box, offering everything needed to build robust HTTP servers without external dependencies • The http.Handler interface and middleware pattern provide elegant composition for building complex server logic from simple, reusable components • Proper server configuration with timeouts and graceful shutdown is essential for production deployments—the defaults are not safe for public-facing services

Introduction to net/http

Go’s net/http package embodies the language’s philosophy of simplicity and practicality. Unlike many languages where you need a framework to build production HTTP servers, Go’s standard library provides everything you need: routing, middleware, TLS support, HTTP/2, and more. This isn’t a minimal toy implementation—companies serve billions of requests using just net/http.

Here’s a complete HTTP server in less than 10 lines:

package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
    })
    http.ListenAndServe(":8080", nil)
}

This server handles requests, parses URLs, manages connections, and supports concurrent requests. That’s the power of net/http.

Request Handling Fundamentals

Everything in net/http revolves around the http.Handler interface:

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

Any type implementing this single method can handle HTTP requests. The http.HandlerFunc adapter lets you use regular functions as handlers:

func greetHandler(w http.ResponseWriter, r *http.Request) {
    name := r.URL.Query().Get("name")
    if name == "" {
        name = "World"
    }
    fmt.Fprintf(w, "Hello, %s!", name)
}

func main() {
    http.HandleFunc("/greet", greetHandler)
    http.ListenAndServe(":8080", nil)
}

For more complex handlers with state, implement the Handler interface directly:

type CounterHandler struct {
    mu    sync.Mutex
    count int
}

func (h *CounterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    h.mu.Lock()
    h.count++
    current := h.count
    h.mu.Unlock()
    
    fmt.Fprintf(w, "Request count: %d", current)
}

func main() {
    counter := &CounterHandler{}
    http.Handle("/count", counter)
    http.ListenAndServe(":8080", nil)
}

Accessing request data is straightforward:

func requestDataHandler(w http.ResponseWriter, r *http.Request) {
    // Query parameters
    id := r.URL.Query().Get("id")
    
    // Headers
    authToken := r.Header.Get("Authorization")
    
    // Request body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Cannot read body", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()
    
    // HTTP method
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
}

Routing and Multiplexing

The http.ServeMux routes requests to handlers based on URL patterns. While basic, it handles most routing needs:

func main() {
    mux := http.NewServeMux()
    
    mux.HandleFunc("/", homeHandler)
    mux.HandleFunc("/api/users", usersHandler)
    mux.HandleFunc("/api/posts/", postsHandler) // Trailing slash matters
    mux.HandleFunc("/health", healthHandler)
    
    http.ListenAndServe(":8080", mux)
}

Pattern matching rules are simple but important:

  • Patterns without trailing slashes match exactly: /api/users matches only that path
  • Patterns with trailing slashes match prefixes: /api/posts/ matches /api/posts/123
  • Longer patterns take precedence over shorter ones
  • The pattern / matches everything not matched elsewhere

Building a REST-like API structure:

func main() {
    mux := http.NewServeMux()
    
    // API routes
    mux.HandleFunc("/api/users", handleUsers)
    mux.HandleFunc("/api/users/", handleUserByID)
    mux.HandleFunc("/api/posts", handlePosts)
    
    http.ListenAndServe(":8080", mux)
}

func handleUsers(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        // List users
    case http.MethodPost:
        // Create user
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func handleUserByID(w http.ResponseWriter, r *http.Request) {
    // Extract ID from path
    id := strings.TrimPrefix(r.URL.Path, "/api/users/")
    
    switch r.Method {
    case http.MethodGet:
        // Get user by ID
    case http.MethodPut:
        // Update user
    case http.MethodDelete:
        // Delete user
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

Middleware Pattern

Middleware wraps handlers to add cross-cutting functionality. The pattern is simple: a function that takes a handler and returns a handler.

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    })
}

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

Chain middleware by wrapping handlers:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", dataHandler)
    
    // Wrap the entire mux
    handler := loggingMiddleware(authMiddleware(mux))
    
    http.ListenAndServe(":8080", handler)
}

For cleaner chaining, create a helper:

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

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/data", dataHandler)
    
    handler := chain(mux, loggingMiddleware, authMiddleware)
    http.ListenAndServe(":8080", handler)
}

JSON APIs and Request/Response Handling

Building JSON APIs requires clean patterns for encoding, decoding, and error handling:

type User struct {
    ID    int    `json:"id"`
    Name  string `json:"name"`
    Email string `json:"email"`
}

func createUserHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
        return
    }
    
    var user User
    if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
        respondError(w, http.StatusBadRequest, "Invalid JSON")
        return
    }
    
    // Validation
    if user.Name == "" || user.Email == "" {
        respondError(w, http.StatusBadRequest, "Name and email required")
        return
    }
    
    // Business logic here
    user.ID = 123
    
    respondJSON(w, http.StatusCreated, user)
}

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

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

This pattern keeps handlers clean and error responses consistent.

Server Configuration and Best Practices

Never use http.ListenAndServe in production without configuring timeouts. The default server has no timeouts, making it vulnerable to slow clients:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)
    
    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
        IdleTimeout:  60 * time.Second,
    }
    
    log.Fatal(server.ListenAndServe())
}

Implement graceful shutdown to finish processing requests before stopping:

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/", homeHandler)
    
    server := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  15 * time.Second,
        WriteTimeout: 15 * time.Second,
    }
    
    // Start server in goroutine
    go func() {
        if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server error: %v", err)
        }
    }()
    
    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
    <-quit
    
    log.Println("Shutting down server...")
    
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    if err := server.Shutdown(ctx); err != nil {
        log.Fatalf("Server forced to shutdown: %v", err)
    }
    
    log.Println("Server exited")
}

For HTTPS, use ListenAndServeTLS:

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

log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))

Conclusion

Go’s net/http package provides everything needed for production HTTP servers. The Handler interface and middleware pattern enable building complex applications from simple, composable pieces. While frameworks like Gin or Echo add conveniences, the standard library is often sufficient—and one less dependency to manage.

Key takeaways: always configure timeouts, implement graceful shutdown, use middleware for cross-cutting concerns, and leverage the Handler interface for clean abstractions. For most applications, net/http plus a router (if you need more than ServeMux) is all you need.

Next steps: explore testing with httptest, context usage for request cancellation, and advanced patterns like connection hijacking for WebSockets.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.