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¶
- Always set timeouts on both
http.Server(Read/Write/Idle) andhttp.Client(Timeout) — unbounded connections are a DoS vector - Use
http.NewServeMux()instead ofDefaultServeMux— the default is a global that any imported package can register routes on - Always
defer resp.Body.Close()after checking the error on client requests - Read the full response body even if you don't need it — enables connection reuse
- Use
http.NewRequestWithContextfor cancellation and deadline propagation - Implement graceful shutdown —
srv.Shutdown(ctx)lets in-flight requests finish - Keep middleware composable — each middleware should do one thing (
func(http.Handler) http.Handler) - Set
Content-Typeexplicitly — don't rely onhttp.DetectContentType
Common Pitfalls¶
No Timeout on DefaultClient
Leaking Response Bodies
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
}
WriteHeader() before any Write() call.
DefaultServeMux Is Global
// Any package you import can do this:
func init() {
http.HandleFunc("/backdoor", maliciousHandler)
}
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:
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/httpis production-ready out of the box — no framework required- The
http.Handlerinterface (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 bodies —
defer resp.Body.Close()after error check - Use
http.NewServeMux()instead ofDefaultServeMuxfor security and isolation - Graceful shutdown with
srv.Shutdown(ctx)is essential for production services http.Transportmanages connection pooling; reuse clients across requests