When building HTTP servers in Go, handling high traffic efficiently is crucial. I’ve worked on several projects where scalability was a key concern, and over time, I’ve identified patterns that make servers robust and performant. In this article, I’ll share eight essential patterns that help create HTTP servers capable of scaling under load while maintaining reliability. Each pattern includes code examples and insights from my experience to make the concepts clear and actionable.
Graceful Shutdown
Graceful shutdown allows a server to finish processing current requests before shutting down. This is important because abruptly stopping a server can lead to data loss or broken user experiences. I once deployed a service without this feature, and during updates, users experienced errors because their requests were cut off mid-process. After adding graceful shutdown, deployments became seamless.
In Go, you can implement graceful shutdown using the http.Server Shutdown method. This method waits for active connections to complete within a specified timeout. Here’s a basic example:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(2 * time.Second) // Simulate work
w.Write([]byte("Hello, World!"))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server shutdown failed: %v", err)
}
log.Println("Server stopped gracefully")
}
This code sets up a server that listens for interrupts, like Ctrl+C, and shuts down gracefully. The context with a 30-second timeout ensures that even long-running requests have time to finish. In practice, I set timeouts based on the average request duration in my applications to balance quick shutdowns and request completion.
Middleware Chains
Middleware helps handle common tasks like logging, authentication, or compression without cluttering your main logic. I think of middleware as layers that wrap around your handlers, each adding a specific behavior. When I first started, I mixed logging and auth code into every handler, which made changes difficult. Using middleware, I can now update these aspects in one place.
In Go, middleware is often implemented as functions that take an http.Handler and return a new one. Here’s an example with logging and authentication middleware:
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("Request: %s %s completed in %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)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Protected data"))
})
// Chain middleware: logging then auth
handler := loggingMiddleware(authMiddleware(mux))
server := &http.Server{
Addr: ":8080",
Handler: handler,
}
log.Fatal(server.ListenAndServe())
}
This code shows how middleware functions wrap each other. The logging middleware records request details, and the auth middleware checks for a valid token. If the token is missing, it stops the request early. I often use this pattern to add features like rate limiting or CORS headers without modifying core handlers.
Request Context Usage
The request context in Go is a powerful tool for passing data through different parts of your application. For instance, you might want to include user information or trace IDs for logging. Early in my career, I passed such data via function arguments, which became messy. Using context made the code cleaner and more consistent.
You can store and retrieve values from the request context. Here’s an example where I add a user ID to the context and use it in a handler:
type key string
const userKey key = "userID"
func addUserMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simulate user authentication
userID := "12345"
ctx := context.WithValue(r.Context(), userKey, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func userHandler(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(userKey).(string)
w.Write([]byte("User ID: " + userID))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/user", userHandler)
handler := addUserMiddleware(mux)
server := &http.Server{
Addr: ":8080",
Handler: handler,
}
log.Fatal(server.ListenAndServe())
}
In this code, the middleware adds a user ID to the context, and the handler retrieves it. This approach is useful for features like authorization or request tracing. I’ve used it to propagate correlation IDs across microservices, making debugging easier in distributed systems.
Connection Management
Proper connection management prevents resource exhaustion and improves server stability. Settings like timeouts control how long the server waits for requests or responses. I learned this the hard way when a slow client tied up all available connections, causing timeouts for other users.
Go’s http.Server allows you to set various timeouts. Here’s an example with configured timeouts:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 30 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
The ReadTimeout limits how long the server waits for the client to send the request, while WriteTimeout limits the time to send the response. IdleTimeout closes connections that have been inactive. In high-traffic environments, I adjust these values based on monitoring data to balance performance and resource usage.
Rate Limiting
Rate limiting protects your server from being overwhelmed by too many requests. It ensures fair usage and prevents abuse. I implemented this after a client accidentally sent thousands of requests per second, crashing the service.
A simple rate limiter can use a token bucket algorithm. Here’s an example middleware that limits requests per IP address:
type rateLimiter struct {
ips map[string]chan time.Time
mu sync.Mutex
}
func newRateLimiter() *rateLimiter {
return &rateLimiter{
ips: make(map[string]chan time.Time),
}
}
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
ch, exists := rl.ips[ip]
if !exists {
ch = make(chan time.Time, 10) // Allow 10 requests
rl.ips[ip] = ch
go func() {
for range time.Tick(time.Second) {
select {
case <-ch:
default:
}
}
}()
}
rl.mu.Unlock()
select {
case ch <- time.Now():
return true
default:
return false
}
}
func rateLimitMiddleware(next http.Handler) http.Handler {
limiter := newRateLimiter()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if !limiter.allow(ip) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello"))
})
handler := rateLimitMiddleware(mux)
server := &http.Server{
Addr: ":8080",
Handler: handler,
}
log.Fatal(server.ListenAndServe())
}
This code tracks requests per IP address using a channel as a token bucket. Each IP can have up to 10 requests, with one token replenished per second. If the bucket is empty, requests are denied. In production, I might use a distributed store for rate limiting across multiple server instances.
Response Compression
Compressing responses reduces bandwidth and speeds up content delivery. This is especially useful for APIs returning large JSON objects. I saw a significant drop in latency after adding compression to a mobile app backend.
Go’s compress/gzip package makes this easy. Here’s a middleware that compresses responses for clients that support it:
import (
"compress/gzip"
"net/http"
"strings"
)
type gzipResponseWriter struct {
http.ResponseWriter
gw *gzip.Writer
}
func (w gzipResponseWriter) Write(b []byte) (int, error) {
return w.gw.Write(b)
}
func gzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
gzw := gzipResponseWriter{ResponseWriter: w, gw: gz}
next.ServeHTTP(gzw, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/data", func(w http.ResponseWriter, r *http.Request) {
data := `{"message": "This is a large JSON response that benefits from compression."}`
w.Write([]byte(data))
})
handler := gzipMiddleware(mux)
server := &http.Server{
Addr: ":8080",
Handler: handler,
}
log.Fatal(server.ListenAndServe())
}
The middleware checks if the client accepts gzip encoding and compresses the response accordingly. I’ve used this to cut response sizes by over 70% for text-based APIs, improving performance for users on slow networks.
Health Checks
Health checks allow external systems, like load balancers, to verify if your server is running properly. They often test dependencies like databases. I add health checks to all my services to enable automated monitoring and recovery.
A simple health check endpoint might look like this:
func healthCheck(w http.ResponseWriter, r *http.Request) {
// Check database connectivity
if err := checkDB(); err != nil {
http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Healthy"))
}
func checkDB() error {
// Simulate database check
return nil // or an error if DB is down
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", healthCheck)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Main handler"))
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
log.Fatal(server.ListenAndServe())
}
This health check verifies database connectivity and returns a 503 status if there’s an issue. In more complex setups, I include checks for other services or cache systems. Load balancers can use this endpoint to stop sending traffic to unhealthy instances.
Error Handling Standardization
Consistent error responses help clients handle failures predictably. When errors vary in format, it complicates client code. I standardized error handling after receiving feedback that our API was hard to integrate with.
Here’s an example of standardized error responses:
type errorResponse struct {
Error string `json:"error"`
Code int `json:"code"`
Message string `json:"message"`
}
func writeError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(errorResponse{
Error: http.StatusText(code),
Code: code,
Message: message,
})
}
func apiHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
writeError(w, http.StatusBadRequest, "Missing id parameter")
return
}
// Process request
w.Write([]byte(`{"data": "success"}`))
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api", apiHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
log.Fatal(server.ListenAndServe())
}
This code defines a common error structure with an error type, code, and message. Clients can always expect this format, making error handling straightforward. I’ve extended this to include details like error IDs for tracking in logs.
Implementing these patterns has helped me build HTTP servers that scale effectively. Start with graceful shutdown and middleware, then add features like rate limiting and compression based on your needs. Regular testing under load ensures your server performs well in real-world conditions. Remember, simplicity and consistency are key to maintaining scalable systems.