Skip to main content
Go intermediate Lesson 23 of 25

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)))
}

Frequently Asked Questions

Do I need a router library for Go HTTP servers?
Not always. The standard net/http ServeMux handles basic routing. Go 1.22 added method and path parameter support to ServeMux, making it usable for many APIs without a library. Use chi or gorilla/mux when you need complex routing patterns.
How do I parse JSON request bodies in Go?
Use json.NewDecoder(r.Body).Decode(&v). Always check the error. Set a read limit with http.MaxBytesReader to protect against large payloads.
How does context propagation work in HTTP handlers?
Every http.Request carries a context via r.Context(). Pass this context to all downstream calls (DB queries, external HTTP calls). When the client disconnects, the context is cancelled — downstream calls using it will also be cancelled.