Skip to content

HTTP Client & Server Intermediate

Introduction

Go's net/http package is a production-grade HTTP toolkit built into the standard library. Unlike most languages where you need a framework (Express, Flask, Spring), Go's stdlib is powerful enough for real services — many companies run net/http directly in production. The package covers both server (handling requests) and client (making requests) sides. Go 1.22 significantly enhanced the default router with method-based and pattern-based routing, closing the gap with third-party routers.


Syntax & Usage

Basic HTTP Server

package main

import (
    "fmt"
    "log"
    "net/http"
)

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

func main() {
    http.HandleFunc("/hello", helloHandler)
    log.Fatal(http.ListenAndServe(":8080", nil)) // nil = DefaultServeMux
}

http.HandleFunc vs http.Handle

// HandleFunc -- register a function directly
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("pong"))
})

// Handle -- register a type that implements http.Handler interface
type healthHandler struct{}

func (h healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"status":"ok"}`))
}

http.Handle("/health", healthHandler{})

The http.Handler Interface

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

// HandlerFunc is an adapter that lets ordinary functions be handlers
type HandlerFunc func(ResponseWriter, *Request)

func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Go 1.22 Enhanced Routing (ServeMux)

Go 1.22 added method-based routing and path parameters to the default ServeMux.

mux := http.NewServeMux()

// Method-based routing (Go 1.22+)
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)

// Path parameters (Go 1.22+)
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id") // extract path parameter
    fmt.Fprintf(w, "User ID: %s", id)
})

// Wildcard: match remaining path
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
    filePath := r.PathValue("path")
    http.ServeFile(w, r, filePath)
})

// Exact match (trailing slash matters)
mux.HandleFunc("GET /api/v1/", apiV1Handler)   // matches /api/v1/* (subtree)
mux.HandleFunc("GET /api/v1",  apiV1Exact)     // matches only /api/v1 (Go 1.22: {$} for exact)
mux.HandleFunc("GET /api/v1/{$}", apiV1Exact)  // explicit exact match

Pre-1.22 Routing Limitations

Before Go 1.22, ServeMux only matched URL paths (no methods, no parameters). You had to check r.Method manually or use third-party routers like chi, gorilla/mux, or gin. Go 1.22 largely eliminates this need.


Middleware Pattern

Middleware in Go follows the func(http.Handler) http.Handler signature — a function that wraps a handler and returns a new handler.

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        log.Printf("Started %s %s", r.Method, r.URL.Path)
        next.ServeHTTP(w, r)
        log.Printf("Completed in %v", 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 == "" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return // don't call next
        }
        next.ServeHTTP(w, r)
    })
}

// Chaining middleware
handler := loggingMiddleware(authMiddleware(http.HandlerFunc(myHandler)))

// Or with a helper
func chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

finalHandler := chain(http.HandlerFunc(myHandler), loggingMiddleware, authMiddleware)

ResponseWriter Interface

type ResponseWriter interface {
    Header() http.Header         // access response headers
    Write([]byte) (int, error)   // write body bytes
    WriteHeader(statusCode int)  // send status code
}

func jsonHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusCreated) // must call before Write
    json.NewEncoder(w).Encode(map[string]string{"status": "created"})
}

WriteHeader Order

WriteHeader() must be called before Write(). Once you call Write(), a 200 OK is sent implicitly. Calling WriteHeader() after Write() has no effect and logs a warning.


Production Server (http.Server Struct)

Never use http.ListenAndServe() in production — it uses the DefaultServeMux and has no timeouts.

mux := http.NewServeMux()
mux.HandleFunc("GET /health", healthCheck)
mux.HandleFunc("POST /api/users", createUser)

srv := &http.Server{
    Addr:         ":8080",
    Handler:      mux,
    ReadTimeout:  5 * time.Second,
    WriteTimeout: 10 * time.Second,
    IdleTimeout:  120 * time.Second,
    // TLS
    // TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12},
}

log.Printf("Server starting on %s", srv.Addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
    log.Fatalf("Server error: %v", err)
}

Graceful Shutdown

func main() {
    srv := &http.Server{Addr: ":8080", Handler: mux}

    // Start server in goroutine
    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("Shutting down server...")

    // Give outstanding requests 30 seconds to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

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

HTTP Client (with Timeout)

// NEVER use http.DefaultClient in production -- it has no timeout
client := &http.Client{
    Timeout: 10 * time.Second,
}

// Simple GET
resp, err := client.Get("https://api.example.com/users")
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
    log.Fatal(err)
}
fmt.Println(string(body))

Making POST Requests

payload := map[string]string{"name": "alice", "email": "alice@example.com"}
jsonData, _ := json.Marshal(payload)

resp, err := client.Post(
    "https://api.example.com/users",
    "application/json",
    bytes.NewBuffer(jsonData),
)
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusCreated {
    log.Fatalf("unexpected status: %s", resp.Status)
}

Custom Request with Context

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com/data", nil)
if err != nil {
    log.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/json")

resp, err := client.Do(req)
if err != nil {
    log.Fatal(err)
}
defer resp.Body.Close()

Custom Transport

transport := &http.Transport{
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 10,
    IdleConnTimeout:     90 * time.Second,
    TLSHandshakeTimeout: 10 * time.Second,
}

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

Connection Reuse

http.Transport maintains a pool of idle connections. Always read the entire response body and close it (defer resp.Body.Close()) to return the connection to the pool. Failing to do so leaks connections.


Quick Reference

Concept Code Notes
Register handler func http.HandleFunc("/path", fn) fn(w, r) signature
Register handler type http.Handle("/path", handler) Must implement http.Handler
Start server http.ListenAndServe(":8080", mux) nil = DefaultServeMux
Method routing (1.22) mux.HandleFunc("GET /path", fn) Method prefix
Path params (1.22) r.PathValue("id") From "GET /users/{id}"
Middleware signature func(http.Handler) http.Handler Wraps and delegates
Set header w.Header().Set("Key", "val") Before WriteHeader/Write
Write status w.WriteHeader(http.StatusOK) Before Write
Client with timeout &http.Client{Timeout: 10*time.Second} Never use DefaultClient
Custom request http.NewRequestWithContext(ctx, ...) For headers, context
Graceful shutdown srv.Shutdown(ctx) Waits for in-flight requests

Best Practices

  1. Always set timeouts on both http.Server (Read/Write/Idle) and http.Client (Timeout) — unbounded connections are a DoS vector
  2. Use http.NewServeMux() instead of DefaultServeMux — the default is a global that any imported package can register routes on
  3. Always defer resp.Body.Close() after checking the error on client requests
  4. Read the full response body even if you don't need it — enables connection reuse
  5. Use http.NewRequestWithContext for cancellation and deadline propagation
  6. Implement graceful shutdownsrv.Shutdown(ctx) lets in-flight requests finish
  7. Keep middleware composable — each middleware should do one thing (func(http.Handler) http.Handler)
  8. Set Content-Type explicitly — don't rely on http.DetectContentType

Common Pitfalls

No Timeout on DefaultClient

// WRONG -- http.DefaultClient has no timeout, can hang forever
resp, err := http.Get("https://slow-api.example.com/data")

// RIGHT
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get("https://slow-api.example.com/data")

Leaking Response Bodies

resp, err := client.Get(url)
if err != nil {
    return err
}
// WRONG -- if you don't read and close the body, the TCP connection leaks
// RIGHT
defer resp.Body.Close()
io.ReadAll(resp.Body) // drain even if you don't need the content

WriteHeader After Write

func handler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello"))       // implicitly sends 200
    w.WriteHeader(http.StatusCreated) // too late! logged as superfluous
}
Always call WriteHeader() before any Write() call.

DefaultServeMux Is Global

// Any package you import can do this:
func init() {
    http.HandleFunc("/backdoor", maliciousHandler)
}
Use http.NewServeMux() for isolation.

Goroutine Leak on Unused Body

If you check resp.StatusCode and return early without reading the body, the underlying connection cannot be reused. Always drain:

defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
    io.Copy(io.Discard, resp.Body) // drain body
    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}


Performance Considerations

Scenario Recommendation
Connection reuse Read and close response bodies; reuse http.Client and http.Transport instances
High-concurrency server Tune MaxIdleConnsPerHost on Transport (default 2 is low)
Large request bodies Use http.MaxBytesReader to limit body size and prevent OOM
JSON responses Use json.NewEncoder(w).Encode() — streams directly without buffer allocation
Static files Use http.FileServer with http.StripPrefix — optimized for file serving
Keep-alive Enabled by default; disable with req.Close = true only when needed
Response buffering ResponseWriter buffers the first Write(); for streaming, use http.Flusher

Interview Tips

Interview Tip

"What interface must an HTTP handler implement?" The http.Handler interface with a single method: ServeHTTP(ResponseWriter, *Request). http.HandlerFunc is an adapter type that lets you use ordinary functions as handlers.

Interview Tip

"What's the middleware pattern in Go?" It's a function with signature func(http.Handler) http.Handler. It takes a handler, wraps it with additional behavior (logging, auth, recovery), and returns a new handler. This creates a composable chain with no framework needed.

Interview Tip

"How do you do graceful shutdown?" Start the server in a goroutine, block on os.Signal (SIGINT/SIGTERM), then call srv.Shutdown(ctx) with a timeout context. This stops accepting new connections and waits for in-flight requests to complete.

Interview Tip

"Why not use http.DefaultClient?" It has no timeout. A slow or unresponsive server will cause your goroutine to hang indefinitely, eventually exhausting resources. Always create an http.Client with an explicit Timeout.

Interview Tip

"What changed in Go 1.22 for routing?" The default ServeMux now supports HTTP method matching ("GET /users") and path parameters ("/users/{id}" with r.PathValue("id")). This eliminates the need for third-party routers in most cases.


Key Takeaways

  • net/http is production-ready out of the box — no framework required
  • The http.Handler interface (ServeHTTP(w, r)) is the foundation of Go's HTTP ecosystem
  • Go 1.22 added method routing and path parameters to ServeMux
  • Middleware follows func(http.Handler) http.Handler — composable by design
  • Always set timeouts on both server (http.Server) and client (http.Client)
  • Always close response bodiesdefer resp.Body.Close() after error check
  • Use http.NewServeMux() instead of DefaultServeMux for security and isolation
  • Graceful shutdown with srv.Shutdown(ctx) is essential for production services
  • http.Transport manages connection pooling; reuse clients across requests