HTTP Servers¶
★★★★★ Intermediate
Go's net/http package provides a production-grade HTTP/2 server with TLS support out of the box. The design centers on the http.Handler interface and composable middleware, requiring no external framework for most use cases.
Key Facts¶
http.Handlerinterface has one method:ServeHTTP(http.ResponseWriter, *http.Request)http.HandlerFuncis a function type that implementshttp.Handler- functions as handlershttp.ServeMuxis the built-in request multiplexer (router), improved significantly in Go 1.22- Go 1.22 added method matching (
GET /users/{id}) and path parameters to the default mux http.ResponseWritermethods must be called in order:Header()->WriteHeader()->Write()- Every request runs in its own goroutine - handlers must be safe for concurrent use
context.Contextis carried on*http.Requestviareq.Context()andreq.WithContext(ctx)
Basic Server¶
package main
import (
"fmt"
"log"
"net/http"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", r.URL.Path[1:])
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name") // Go 1.22+ path parameter
fmt.Fprintf(w, "Hello, %s!", name)
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
Routing (Go 1.22+)¶
mux := http.NewServeMux()
// Method + path matching
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
// Wildcard - matches remaining path
mux.HandleFunc("GET /files/{path...}", serveFile)
// Extract path parameters
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
}
Before Go 1.22¶
Without method matching, you had to dispatch manually or use a third-party router:
// Manual method dispatch (pre-1.22 pattern)
mux.HandleFunc("/users/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
getUser(w, r)
case http.MethodPut:
updateUser(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
Middleware¶
Middleware wraps a handler to add cross-cutting behavior (logging, auth, CORS, etc.).
// Middleware signature: takes Handler, returns 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 == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Validate token, add user to context
ctx := context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Chain middleware
handler := loggingMiddleware(authMiddleware(mux))
server := &http.Server{Handler: handler}
Middleware Chain Helper¶
type Middleware func(http.Handler) http.Handler
func chain(h http.Handler, middlewares ...Middleware) http.Handler {
for i := len(middlewares) - 1; i >= 0; i-- {
h = middlewares[i](h)
}
return h
}
// Usage: request flows through logging -> auth -> recovery -> handler
handler := chain(mux, loggingMiddleware, authMiddleware, recoveryMiddleware)
JSON API Patterns¶
func createUser(w http.ResponseWriter, r *http.Request) {
// Decode request body
var input struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
user, err := db.CreateUser(r.Context(), input.Name, input.Email)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Encode response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(user)
}
Response Helper¶
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(data); err != nil {
log.Printf("failed to encode response: %v", err)
}
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
Context in HTTP¶
// Extract context from request
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Pass context to downstream calls (DB, external APIs)
result, err := db.Query(ctx, "SELECT ...")
if err != nil {
// If context was cancelled (client disconnected), ctx.Err() != nil
if ctx.Err() != nil {
return // client gone, no point responding
}
http.Error(w, "db error", 500)
return
}
}
// Add values to context in middleware
type contextKey string
const userKey contextKey = "user"
func getUserFromContext(ctx context.Context) (*User, bool) {
user, ok := ctx.Value(userKey).(*User)
return user, ok
}
HTTP Client¶
// Always create a client with timeouts - never use http.DefaultClient
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Get("https://api.example.com/data")
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status: %s", resp.Status)
}
var data MyResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return fmt.Errorf("decode response: %w", err)
}
Gotchas¶
- WriteHeader can only be called once - calling
WriteHeadera second time is silently ignored. If you callWrite()beforeWriteHeader(), Go implicitly sends 200. This means writing an error message after already starting a 200 response produces a warning in logs and a confusing response to the client - Default ServeMux is a global -
http.HandleFunc(without a mux) registers onhttp.DefaultServeMux, a package-level global. Any imported package can register handlers on it. Always create your ownhttp.NewServeMux()in production - http.DefaultClient has no timeout -
http.Get()andhttp.Post()usehttp.DefaultClientwhich has zero timeout. A slow/unresponsive server will block your goroutine forever. Always create a custom client with explicit timeouts - Request body must be closed - failing to call
resp.Body.Close()leaks the TCP connection. Usedefer resp.Body.Close()immediately after checkingerr
See Also¶
- goroutines channels - each HTTP request runs in its own goroutine
- interfaces composition -
http.Handlerinterface and middleware pattern - error handling - error handling in HTTP handlers and clients
- modules packages - organizing HTTP handlers across packages