Go net/http: Building HTTP Servers
• Go's `net/http` package is production-ready out of the box, offering everything needed to build robust HTTP servers without external dependencies
Key Insights
• Go’s net/http package is production-ready out of the box, offering everything needed to build robust HTTP servers without external dependencies
• The http.Handler interface and middleware pattern provide elegant composition for building complex server logic from simple, reusable components
• Proper server configuration with timeouts and graceful shutdown is essential for production deployments—the defaults are not safe for public-facing services
Introduction to net/http
Go’s net/http package embodies the language’s philosophy of simplicity and practicality. Unlike many languages where you need a framework to build production HTTP servers, Go’s standard library provides everything you need: routing, middleware, TLS support, HTTP/2, and more. This isn’t a minimal toy implementation—companies serve billions of requests using just net/http.
Here’s a complete HTTP server in less than 10 lines:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
})
http.ListenAndServe(":8080", nil)
}
This server handles requests, parses URLs, manages connections, and supports concurrent requests. That’s the power of net/http.
Request Handling Fundamentals
Everything in net/http revolves around the http.Handler interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Any type implementing this single method can handle HTTP requests. The http.HandlerFunc adapter lets you use regular functions as handlers:
func greetHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "World"
}
fmt.Fprintf(w, "Hello, %s!", name)
}
func main() {
http.HandleFunc("/greet", greetHandler)
http.ListenAndServe(":8080", nil)
}
For more complex handlers with state, implement the Handler interface directly:
type CounterHandler struct {
mu sync.Mutex
count int
}
func (h *CounterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mu.Lock()
h.count++
current := h.count
h.mu.Unlock()
fmt.Fprintf(w, "Request count: %d", current)
}
func main() {
counter := &CounterHandler{}
http.Handle("/count", counter)
http.ListenAndServe(":8080", nil)
}
Accessing request data is straightforward:
func requestDataHandler(w http.ResponseWriter, r *http.Request) {
// Query parameters
id := r.URL.Query().Get("id")
// Headers
authToken := r.Header.Get("Authorization")
// Request body
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Cannot read body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// HTTP method
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
}
Routing and Multiplexing
The http.ServeMux routes requests to handlers based on URL patterns. While basic, it handles most routing needs:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
mux.HandleFunc("/api/users", usersHandler)
mux.HandleFunc("/api/posts/", postsHandler) // Trailing slash matters
mux.HandleFunc("/health", healthHandler)
http.ListenAndServe(":8080", mux)
}
Pattern matching rules are simple but important:
- Patterns without trailing slashes match exactly:
/api/usersmatches only that path - Patterns with trailing slashes match prefixes:
/api/posts/matches/api/posts/123 - Longer patterns take precedence over shorter ones
- The pattern
/matches everything not matched elsewhere
Building a REST-like API structure:
func main() {
mux := http.NewServeMux()
// API routes
mux.HandleFunc("/api/users", handleUsers)
mux.HandleFunc("/api/users/", handleUserByID)
mux.HandleFunc("/api/posts", handlePosts)
http.ListenAndServe(":8080", mux)
}
func handleUsers(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// List users
case http.MethodPost:
// Create user
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func handleUserByID(w http.ResponseWriter, r *http.Request) {
// Extract ID from path
id := strings.TrimPrefix(r.URL.Path, "/api/users/")
switch r.Method {
case http.MethodGet:
// Get user by ID
case http.MethodPut:
// Update user
case http.MethodDelete:
// Delete user
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
Middleware Pattern
Middleware wraps handlers to add cross-cutting functionality. The pattern is simple: a function that takes a handler and returns a handler.
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("%s %s %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)
})
}
Chain middleware by wrapping handlers:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", dataHandler)
// Wrap the entire mux
handler := loggingMiddleware(authMiddleware(mux))
http.ListenAndServe(":8080", handler)
}
For cleaner chaining, create a helper:
func chain(h http.Handler, middleware ...func(http.Handler) http.Handler) http.Handler {
for i := len(middleware) - 1; i >= 0; i-- {
h = middleware[i](h)
}
return h
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/data", dataHandler)
handler := chain(mux, loggingMiddleware, authMiddleware)
http.ListenAndServe(":8080", handler)
}
JSON APIs and Request/Response Handling
Building JSON APIs requires clean patterns for encoding, decoding, and error handling:
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
func createUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
respondError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
var user User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
respondError(w, http.StatusBadRequest, "Invalid JSON")
return
}
// Validation
if user.Name == "" || user.Email == "" {
respondError(w, http.StatusBadRequest, "Name and email required")
return
}
// Business logic here
user.ID = 123
respondJSON(w, http.StatusCreated, user)
}
func respondJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, map[string]string{"error": message})
}
This pattern keeps handlers clean and error responses consistent.
Server Configuration and Best Practices
Never use http.ListenAndServe in production without configuring timeouts. The default server has no timeouts, making it vulnerable to slow clients:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
Implement graceful shutdown to finish processing requests before stopping:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", homeHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
}
// Start server in goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Wait for interrupt signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
For HTTPS, use ListenAndServeTLS:
server := &http.Server{
Addr: ":443",
Handler: mux,
}
log.Fatal(server.ListenAndServeTLS("cert.pem", "key.pem"))
Conclusion
Go’s net/http package provides everything needed for production HTTP servers. The Handler interface and middleware pattern enable building complex applications from simple, composable pieces. While frameworks like Gin or Echo add conveniences, the standard library is often sufficient—and one less dependency to manage.
Key takeaways: always configure timeouts, implement graceful shutdown, use middleware for cross-cutting concerns, and leverage the Handler interface for clean abstractions. For most applications, net/http plus a router (if you need more than ServeMux) is all you need.
Next steps: explore testing with httptest, context usage for request cancellation, and advanced patterns like connection hijacking for WebSockets.