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 http.Server Type: Taking Control
- Request Handling Patterns
- Request Body Handling
- Response Writing
- Middleware: The Swiss Army Knife
- Context: Request-Scoped Values and Cancellation
- The http.Client: Making Requests
- Testing HTTP Handlers
- Production Patterns
- HTTPS and TLS
- Common Pitfalls and Solutions
- Performance Considerations
- Final Thoughts
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:
- ReadTimeout: Prevents slow clients from holding connections open
- WriteTimeout: Ensures responses are sent in a reasonable time
- IdleTimeout: Closes keep-alive connections after inactivity
- Graceful shutdown: Allows in-flight requests to complete
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.