HTTP Server in Go
Build HTTP servers with net/http, add routing and middleware, serve JSON APIs, and propagate context through request handlers.
Basic HTTP Server
Go’s net/http package includes a production-capable HTTP server in the standard library — no third-party framework needed to get started. The most important thing to know: never use http.ListenAndServe directly in production. It creates a server with no timeouts, which means a slow client can hold a connection open indefinitely, eventually exhausting your server’s file descriptors. Always construct an http.Server struct and set read, write, and idle timeouts explicitly.
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func helloHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, Go!")
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", helloHandler)
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{"status":"ok"}`)
})
// Always use http.Server with explicit timeouts — http.ListenAndServe has none
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 15 * time.Second, // time to read the full request including body
WriteTimeout: 15 * time.Second, // time to write the full response
IdleTimeout: 60 * time.Second, // time to keep idle keep-alive connections open
}
log.Println("listening on :8080")
log.Fatal(srv.ListenAndServe())
}
Go 1.22 Enhanced Routing
Before Go 1.22, ServeMux only matched paths — you had to check r.Method inside each handler and parse path parameters manually. Go 1.22 added method matching and {name} path parameters directly to the mux pattern syntax. This covers the majority of REST API routing needs without any external library, and the standard library solution carries zero dependency risk.
mux := http.NewServeMux()
// Method + path patterns — Go 1.22+
mux.HandleFunc("GET /users", listUsers) // only matches GET
mux.HandleFunc("POST /users", createUser) // only matches POST
mux.HandleFunc("GET /users/{id}", getUser) // {id} captures the path segment
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extract the captured path parameter
fmt.Fprintf(w, "user id: %s\n", id)
}
JSON API Handler Pattern
Most Go API handlers follow the same structure: limit the request body size, decode JSON, validate the input, call the service layer, and write a JSON response. Extracting writeJSON and writeError helpers eliminates repetition and ensures consistent response formatting across all handlers. Closures let you inject dependencies (like a service) into handlers without global variables.
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email"`
}
// writeJSON sets the Content-Type header and encodes v as JSON
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("writeJSON: %v", err)
}
}
// writeError writes a standard {"error": "..."} JSON response
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// createUserHandler uses a closure to inject the service dependency
func createUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Limit body size — prevents memory exhaustion from huge payloads
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB max
var req CreateUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// Validate inputs before touching the service layer
if req.Name == "" {
writeError(w, http.StatusUnprocessableEntity, "name is required")
return
}
if !strings.Contains(req.Email, "@") {
writeError(w, http.StatusUnprocessableEntity, "invalid email")
return
}
// Pass r.Context() — if the client disconnects, the service call is cancelled
user, err := svc.Create(r.Context(), req.Name, req.Email)
if err != nil {
if errors.Is(err, ErrConflict) {
writeError(w, http.StatusConflict, "email already registered")
return
}
log.Printf("createUser: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusCreated, user)
}
}
Middleware
Middleware wraps an http.Handler to add cross-cutting behaviour — logging, authentication, rate limiting, recovery from panics — without touching individual handler functions. Each middleware function receives the next handler and returns a new handler that calls next.ServeHTTP at the appropriate point. Stacking middlewares with a Chain helper keeps main.go readable and the order of operations explicit.
// responseWriter wraps http.ResponseWriter to capture the status code for logging
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
// loggingMiddleware logs method, path, status code, and duration for every request
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &responseWriter{ResponseWriter: w, status: 200}
next.ServeHTTP(rw, r) // call the next handler
log.Printf("%s %s %d %v", r.Method, r.URL.Path, rw.status, time.Since(start))
})
}
// recoveryMiddleware catches panics in handlers and returns 500 instead of crashing
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: %v\n%s", err, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
// corsMiddleware adds CORS headers — adjust allowed origins in production
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
Context Propagation
Every http.Request carries a context.Context that is automatically cancelled when the client disconnects. Passing r.Context() to every downstream call — database queries, outbound HTTP requests, cache lookups — ensures that work stops immediately when the client is no longer waiting for it. This prevents wasted CPU and database load from serving responses nobody will receive. Use a custom unexported type for context keys to prevent collisions between packages.
// Use an unexported type for context keys — prevents key collisions between packages
type contextKey string
const (
ctxUserID contextKey = "userID"
ctxReqID contextKey = "requestID"
)
// authMiddleware validates a JWT and stores the user ID in the request context
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
userID, err := validateJWT(token)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Store userID in the context — downstream handlers retrieve it with Value()
ctx := context.WithValue(r.Context(), ctxUserID, userID)
next.ServeHTTP(w, r.WithContext(ctx)) // replace the request's context
})
}
// requestIDMiddleware assigns a unique ID to every request for distributed tracing
func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := uuid.New().String()
ctx := context.WithValue(r.Context(), ctxReqID, reqID)
w.Header().Set("X-Request-ID", reqID) // echo it back in the response too
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Handler retrieves values from context and passes context to all downstream calls
func profileHandler(w http.ResponseWriter, r *http.Request) {
userID, ok := r.Context().Value(ctxUserID).(int)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// r.Context() is cancelled if the client disconnects — DB call stops automatically
user, err := db.FindUser(r.Context(), userID)
// ...
}
Graceful Shutdown
A server that terminates abruptly mid-request leaves clients with broken responses and may corrupt in-flight state. Graceful shutdown waits for all active requests to finish before the process exits. The pattern: start the server in a goroutine, listen for OS signals (SIGINT, SIGTERM) in the main goroutine, then call srv.Shutdown with a deadline. Kubernetes sends SIGTERM before killing a pod — handling it correctly gives your pod time to drain.
func main() {
mux := http.NewServeMux()
// register handlers...
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start the server in a background goroutine — ListenAndServe blocks
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err) // unexpected error — crash immediately
}
// http.ErrServerClosed is expected after Shutdown() is called — not an error
}()
log.Println("server started on :8080")
// Block until we receive SIGINT (Ctrl+C) or SIGTERM (from Kubernetes/systemd)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
// Give in-flight requests up to 30 seconds to complete
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("shutdown: %v", err)
}
log.Println("server stopped")
}
Complete Mini REST API
This example pulls together the patterns above into a self-contained TODO API: a concurrent-safe in-memory store, method-aware routing, JSON responses, and the logging middleware. It’s a working template you can extend with a real database and additional middleware.
package main
import (
"encoding/json"
"log"
"net/http"
"strconv"
"sync"
)
type Todo struct {
ID int `json:"id"`
Text string `json:"text"`
Done bool `json:"done"`
}
// Store is a concurrent-safe in-memory TODO store
type Store struct {
mu sync.RWMutex
todos map[int]Todo
next int
}
func NewStore() *Store { return &Store{todos: make(map[int]Todo)} }
func (s *Store) List() []Todo {
s.mu.RLock() // multiple readers can List concurrently
defer s.mu.RUnlock()
result := make([]Todo, 0, len(s.todos))
for _, t := range s.todos {
result = append(result, t)
}
return result
}
func (s *Store) Create(text string) Todo {
s.mu.Lock() // exclusive lock for writes
defer s.mu.Unlock()
s.next++
t := Todo{ID: s.next, Text: text}
s.todos[t.ID] = t
return t
}
func main() {
store := NewStore()
mux := http.NewServeMux()
// GET /todos — return all todos as JSON
mux.HandleFunc("GET /todos", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(store.List())
})
// POST /todos — create a new todo from the JSON body
mux.HandleFunc("POST /todos", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Text string `json:"text"`
}
json.NewDecoder(r.Body).Decode(&body)
if body.Text == "" {
http.Error(w, `{"error":"text required"}`, 422)
return
}
t := store.Create(body.Text)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(201)
json.NewEncoder(w).Encode(t)
})
log.Fatal(http.ListenAndServe(":8080", loggingMiddleware(mux)))
}