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:#f3e5f5HTTP 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:#fff3e0func 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 tohttp.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 likegorilla/muxorchi.
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:#fce4ecpackage 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:#e8f5e9Creating 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.Serverwith explicit timeouts in production —ReadTimeout,WriteTimeout, andIdleTimeoutprotect the server from Slowloris attacks and resource exhaustion.- Create your own
http.ServeMux, don’t use the globalhttp.DefaultServeMux— safer, testable, and no potential conflicts with other packages.- The response writing order must be followed:
Header().Set()→WriteHeader()→Write(). Headers set afterWriteHeaderwon’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.DefaultClientin production because it has no timeout — create anhttp.Clientwith an explicitTimeout.- Middleware runs outside to inside — the
Recovery → Logging → Auth → Handlerorder 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.MaxBytesReaderis 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 inServeMux— often enough without an external router.- Health check endpoints (
/healthor/healthz) are the standard for container deployments — Kubernetes and load balancers use them for traffic routing.