Web Server #

The net/http package in Go is one of the strongest standard libraries in the world — you can build a production-grade web server without a single external dependency. Many Go teams choose not to use any framework at all because net/http already covers almost everything: routing, middleware, static files, HTTPS, HTTP/2, timeouts, and graceful shutdown. This article covers everything you need to know to build a proper web server in Go.

The flow of an incoming HTTP request from the browser/client to the resulting response from the Go server can be visualized in the following diagram:

flowchart TD
    Client["Client (Browser / API Client)"] -->|"Send HTTP Request"| Server["http.Server"]
    Server --> Mux["http.ServeMux (Router)"]
    Mux -->|"Pass Through Middleware Chain"| MW1["Middleware 1 (Logging)"]
    MW1 --> MW2["Middleware 2 (Auth)"]
    MW2 --> Handler["http.Handler (Business Logic)"]
    Handler -->|"Write Response"| Resp["http.ResponseWriter"]
    Resp -->|"Send HTTP Response"| Client

http.Handler — The Foundation of Everything #

The entire net/http is built on a single interface:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Anything implementing ServeHTTP is a valid handler. http.HandlerFunc is an adapter type that turns an ordinary function into an http.Handler:

// An ordinary function
func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "Hello, World!")
}

// Convert to a Handler
var handler http.Handler = http.HandlerFunc(hello)

// The HandleFunc shortcut — the most commonly used
http.HandleFunc("/hello", hello)

http.ServeMux — The Built-in Router #

http.ServeMux is Go’s built-in request multiplexer. Since Go 1.22, ServeMux supports method routing and path parameters without any external library:

mux := http.NewServeMux()

// Go 1.22+: method + path
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)        // path parameter
mux.HandleFunc("PUT /users/{id}", updateUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)

// The old way (all methods, all Go versions)
mux.HandleFunc("/api/", apiHandler)   // trailing slash = prefix match
mux.HandleFunc("/health", healthCheck) // exact match

// Get a path parameter (Go 1.22+)
func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")  // get {id} from the path
    fmt.Fprintf(w, "User ID: %s", id)
}

http.Server — Proper Configuration #

Don’t use http.ListenAndServe directly in production — it doesn’t set timeouts, making it vulnerable to Slowloris attacks:

srv := &http.Server{
    Addr:    ":8080",
    Handler: mux,

    // Timeouts are mandatory for production
    ReadTimeout:       5 * time.Second,   // time limit for reading the entire request
    ReadHeaderTimeout: 2 * time.Second,   // time limit for reading headers only
    WriteTimeout:      10 * time.Second,  // time limit for writing the response
    IdleTimeout:       120 * time.Second, // time limit for idle (keep-alive) connections

    MaxHeaderBytes: 1 << 20,  // 1MB max header size
}

log.Fatal(srv.ListenAndServe())

Graceful Shutdown #

A server that can stop cleanly — finishing in-flight requests before exiting:

func main() {
    mux := http.NewServeMux()
    // ... register routes

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    // Run the server in a separate goroutine
    go func() {
        log.Println("Server running on :8080")
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal("ListenAndServe:", err)
        }
    }()

    // Wait for an OS signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("Received shutdown signal...")

    // Give 30 seconds for active requests to finish
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal("Forced shutdown:", err)
    }
    log.Println("Server stopped cleanly")
}

Reading Requests #

func handler(w http.ResponseWriter, r *http.Request) {
    // Method and URL
    fmt.Println(r.Method)       // GET, POST, etc.
    fmt.Println(r.URL.Path)     // /users/42
    fmt.Println(r.URL.String()) // /users/42?sort=name

    // Query parameters
    name := r.URL.Query().Get("name")          // ?name=budi
    tags := r.URL.Query()["tags"]              // ?tags=a&tags=b → []string
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))

    // Headers
    contentType := r.Header.Get("Content-Type")
    token := r.Header.Get("Authorization")

    // Path value (Go 1.22+)
    id := r.PathValue("id")

    // Body — only for POST/PUT/PATCH
    defer r.Body.Close()

    // Read as bytes
    body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))  // max 1MB
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }

    // Decode a JSON body
    var payload struct {
        Name  string `json:"name"`
        Email string `json:"email"`
    }
    if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
        http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
        return
    }

    // Form data
    r.ParseForm()
    username := r.FormValue("username")
    _ = username

    // Multipart form (file upload)
    r.ParseMultipartForm(10 << 20)  // 10MB
    file, header, err := r.FormFile("avatar")
    if err == nil {
        defer file.Close()
        fmt.Println("Upload:", header.Filename, header.Size)
    }

    // Cookie
    cookie, err := r.Cookie("session_id")
    if err == nil {
        fmt.Println("Session:", cookie.Value)
    }

    _ = name; _ = tags; _ = page; _ = contentType; _ = token; _ = id; _ = body
}

Writing Responses #

func respond(w http.ResponseWriter, r *http.Request) {
    // Set headers before WriteHeader or Write
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("X-Request-ID", "abc123")

    // Set the status code (default 200 if not called)
    w.WriteHeader(http.StatusCreated)  // 201

    // Write the body
    json.NewEncoder(w).Encode(map[string]any{
        "id":      42,
        "message": "created successfully",
    })
}

// Helper for consistent JSON responses
type APIResponse struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Error   string      `json:"error,omitempty"`
}

func writeJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(data)
}

func writeError(w http.ResponseWriter, status int, msg string) {
    writeJSON(w, status, APIResponse{Success: false, Error: msg})
}

// Redirect
http.Redirect(w, r, "/login", http.StatusFound)  // 302

// Set a cookie
http.SetCookie(w, &http.Cookie{
    Name:     "session_id",
    Value:    "abc123",
    Path:     "/",
    HttpOnly: true,
    Secure:   true,
    SameSite: http.SameSiteLaxMode,
    MaxAge:   86400,  // 1 day
})

Middleware #

Middleware is a function that wraps a handler to add functionality. The standard pattern:

type Middleware func(http.Handler) http.Handler

// Logging middleware
func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        // Wrap the ResponseWriter to capture the status code
        rw := &responseWriter{ResponseWriter: w, status: 200}
        next.ServeHTTP(rw, r)
        log.Printf("%s %s %d %v", r.Method, r.URL.Path, rw.status, time.Since(start))
    })
}

type responseWriter struct {
    http.ResponseWriter
    status int
}

func (rw *responseWriter) WriteHeader(status int) {
    rw.status = status
    rw.ResponseWriter.WriteHeader(status)
}

// Recovery middleware — catch panics so the server doesn't crash
func recovery(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)
    })
}

// CORS middleware
func cors(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)
    })
}

// Auth middleware
func requireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        token := r.Header.Get("Authorization")
        if !strings.HasPrefix(token, "Bearer ") {
            writeError(w, http.StatusUnauthorized, "token required")
            return
        }
        // validate the token...
        next.ServeHTTP(w, r)
    })
}

// Chaining middleware — applied right to left
func chain(h http.Handler, middlewares ...Middleware) http.Handler {
    for i := len(middlewares) - 1; i >= 0; i-- {
        h = middlewares[i](h)
    }
    return h
}

// Usage
handler := chain(mux, logging, recovery, cors)

Context — Passing Data Between Middlewares #

type contextKey string

const (
    userIDKey    contextKey = "userID"
    requestIDKey contextKey = "requestID"
)

// Middleware that stores data into the context
func withRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := generateRequestID()
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Handler that reads data from the context
func getHandler(w http.ResponseWriter, r *http.Request) {
    reqID := r.Context().Value(requestIDKey).(string)
    log.Printf("[%s] Handling request", reqID)
}

Static Files #

// Serve a local directory
fs := http.FileServer(http.Dir("./static"))
mux.Handle("/static/", http.StripPrefix("/static/", fs))

// Serve embedded files (Go 1.16+)
//go:embed static/*
var staticFiles embed.FS

subFS, _ := fs.Sub(staticFiles, "static")
mux.Handle("/static/", http.StripPrefix("/static/",
    http.FileServer(http.FS(subFS))))

// Serve a single file
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) {
    http.ServeFile(w, r, "./static/favicon.ico")
})

The HTTP Client #

net/http also provides a powerful HTTP client:

// Don't use http.DefaultClient in production — no timeout!
client := &http.Client{
    Timeout: 10 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:       100,
        IdleConnTimeout:    90 * time.Second,
        DisableCompression: false,
    },
}

// GET
resp, err := client.Get("https://api.example.com/users")
if err != nil {
    return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
var users []User
json.NewDecoder(resp.Body).Decode(&users)

// POST JSON
payload, _ := json.Marshal(map[string]string{"name": "Budi"})
resp2, err := client.Post("https://api.example.com/users",
    "application/json", bytes.NewReader(payload))

// Custom request with headers
req, _ := http.NewRequestWithContext(ctx, "DELETE",
    "https://api.example.com/users/42", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp3, err := client.Do(req)
defer resp3.Body.Close()

Complete Example Program — REST API #

package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "os"
    "os/signal"
    "strconv"
    "sync"
    "syscall"
    "time"
)

// ── Model ─────────────────────────────────────────────────────

type Product struct {
    ID       int     `json:"id"`
    Name     string  `json:"name"`
    Price    float64 `json:"price"`
    Stock    int     `json:"stock"`
    Category string  `json:"category"`
}

// ── In-Memory Store ───────────────────────────────────────────

type Store struct {
    mu       sync.RWMutex
    products map[int]Product
    nextID   int
}

func NewStore() *Store {
    s := &Store{products: make(map[int]Product)}
    // Seed data
    for _, p := range []Product{
        {Name: "Pro Laptop", Price: 15_000_000, Stock: 10, Category: "electronics"},
        {Name: "Wireless Mouse", Price: 350_000, Stock: 50, Category: "electronics"},
        {Name: "Go Book", Price: 180_000, Stock: 30, Category: "books"},
    } {
        s.nextID++
        p.ID = s.nextID
        s.products[p.ID] = p
    }
    return s
}

func (s *Store) List() []Product {
    s.mu.RLock()
    defer s.mu.RUnlock()
    list := make([]Product, 0, len(s.products))
    for _, p := range s.products {
        list = append(list, p)
    }
    return list
}

func (s *Store) Get(id int) (Product, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    p, ok := s.products[id]
    return p, ok
}

func (s *Store) Create(p Product) Product {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.nextID++
    p.ID = s.nextID
    s.products[p.ID] = p
    return p
}

func (s *Store) Update(id int, p Product) (Product, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, ok := s.products[id]; !ok {
        return Product{}, false
    }
    p.ID = id
    s.products[id] = p
    return p, true
}

func (s *Store) Delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()
    if _, ok := s.products[id]; !ok {
        return false
    }
    delete(s.products, id)
    return true
}

// ── Handler ───────────────────────────────────────────────────

type Handler struct{ store *Store }

func (h *Handler) respond(w http.ResponseWriter, status int, data any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]any{"success": status < 400, "data": data})
}

func (h *Handler) respondError(w http.ResponseWriter, status int, msg string) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(map[string]any{"success": false, "error": msg})
}

func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
    products := h.store.List()
    // Filter by category
    if cat := r.URL.Query().Get("category"); cat != "" {
        filtered := products[:0]
        for _, p := range products {
            if p.Category == cat {
                filtered = append(filtered, p)
            }
        }
        products = filtered
    }
    h.respond(w, http.StatusOK, products)
}

func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        h.respondError(w, http.StatusBadRequest, "invalid ID")
        return
    }
    p, ok := h.store.Get(id)
    if !ok {
        h.respondError(w, http.StatusNotFound, "product not found")
        return
    }
    h.respond(w, http.StatusOK, p)
}

func (h *Handler) create(w http.ResponseWriter, r *http.Request) {
    var p Product
    if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
        h.respondError(w, http.StatusBadRequest, "invalid JSON")
        return
    }
    if p.Name == "" {
        h.respondError(w, http.StatusBadRequest, "name is required")
        return
    }
    created := h.store.Create(p)
    h.respond(w, http.StatusCreated, created)
}

func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        h.respondError(w, http.StatusBadRequest, "invalid ID")
        return
    }
    var p Product
    if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
        h.respondError(w, http.StatusBadRequest, "invalid JSON")
        return
    }
    updated, ok := h.store.Update(id, p)
    if !ok {
        h.respondError(w, http.StatusNotFound, "product not found")
        return
    }
    h.respond(w, http.StatusOK, updated)
}

func (h *Handler) delete(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        h.respondError(w, http.StatusBadRequest, "invalid ID")
        return
    }
    if !h.store.Delete(id) {
        h.respondError(w, http.StatusNotFound, "product not found")
        return
    }
    h.respond(w, http.StatusOK, map[string]string{"message": "deleted successfully"})
}

// ── Middleware ────────────────────────────────────────────────

func logging(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 cors(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)
    })
}

// ── Main ──────────────────────────────────────────────────────

func main() {
    store := NewStore()
    h := &Handler{store: store}

    mux := http.NewServeMux()

    // Routes (Go 1.22+)
    mux.HandleFunc("GET /api/products", h.list)
    mux.HandleFunc("POST /api/products", h.create)
    mux.HandleFunc("GET /api/products/{id}", h.get)
    mux.HandleFunc("PUT /api/products/{id}", h.update)
    mux.HandleFunc("DELETE /api/products/{id}", h.delete)
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
    })

    // Apply the middleware
    handler := logging(cors(mux))

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
        IdleTimeout:  120 * time.Second,
    }

    go func() {
        log.Println("REST API running on :8080")
        log.Println("Try: curl http://localhost:8080/api/products")
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
    log.Println("Server stopped")
}

Summary #

  • net/http is already very complete — you don’t always need a framework; add dependencies only when truly necessary.
  • Always configure timeouts on http.ServerReadTimeout, WriteTimeout, IdleTimeout are mandatory in production.
  • Graceful shutdown with srv.Shutdown(ctx) so in-flight requests finish before the server stops.
  • Go 1.22+: ServeMux already supports method routing (GET /path) and path parameters ({id}) without external libraries.
  • Middleware is implemented as func(http.Handler) http.Handler — composable and chainable.
  • Always defer r.Body.Close() and use io.LimitReader when reading bodies to prevent memory exhaustion.
  • Write headers before WriteHeader — after WriteHeader is called, headers can’t be changed.
  • Production HTTP client: create your own &http.Client{Timeout: ...}http.DefaultClient has no timeout.
  • Context passes data (request IDs, users) between middlewares and handlers without extra parameters.
  • Static files can be embedded into the binary with //go:embed and http.FS() — no external files needed at deployment.

← Previous: WebSocket   Next: Unit Testing →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact