Skip to content
AgileLogics
8 min read

Building an HTTP Server

Prerequisites: Making HTTP requests

Go's standard library includes a production-grade HTTP server, so many Go services need no web framework.

  • A handler is anything with a ServeHTTP(http.ResponseWriter, *http.Request) method; http.HandlerFunc lets an ordinary function be one.
  • An http.ServeMux routes requests to handlers. Since Go 1.22, patterns can include a method and wildcards: "GET /items/{id}". Read a wildcard with r.PathValue("id").
  • The mux answers 404 Not Found for unknown paths and 405 Method Not Allowed when the path matches but the method doesn't.
  • Each request runs in its own goroutine, so shared state needs a mutex.

Syntax

mux := http.NewServeMux()
mux.HandleFunc("GET /items/{id}", getItem)
mux.HandleFunc("POST /items", createItem)

func getItem(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    fmt.Fprintf(w, "item %s", id)
}

log.Fatal(http.ListenAndServe(":8080", mux))

Example

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "sync"
    "time"
)

type Item struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

type store struct {
    mu     sync.Mutex
    items  map[int]Item
    nextID int
}

func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json") // headers before WriteHeader
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(v); err != nil {
        log.Println("encode response:", err)
    }
}

func (s *store) getItem(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return
    }

    s.mu.Lock()
    item, ok := s.items[id]
    s.mu.Unlock()

    if !ok {
        http.Error(w, "item not found", http.StatusNotFound)
        return
    }
    writeJSON(w, http.StatusOK, item)
}

func (s *store) createItem(w http.ResponseWriter, r *http.Request) {
    var in struct {
        Name string `json:"name"`
    }
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // reject bodies over 1 MB
    if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Name == "" {
        http.Error(w, "invalid body", http.StatusBadRequest)
        return
    }

    s.mu.Lock()
    s.nextID++
    item := Item{ID: s.nextID, Name: in.Name}
    s.items[item.ID] = item
    s.mu.Unlock()

    writeJSON(w, http.StatusCreated, item)
}

func main() {
    s := &store{items: make(map[int]Item)}

    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("ok\n"))
    })
    mux.HandleFunc("GET /items/{id}", s.getItem)
    mux.HandleFunc("POST /items", s.createItem)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      mux,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 10 * time.Second,
    }
    log.Println("listening on", srv.Addr)
    log.Fatal(srv.ListenAndServe())
}

Expected output

Run the server with go run ., then in a second terminal:

$ curl localhost:8080/health
ok
$ curl -X POST localhost:8080/items -d '{"name":"pen"}'
{"id":1,"name":"pen"}
$ curl localhost:8080/items/1
{"id":1,"name":"pen"}
$ curl localhost:8080/items/42
item not found
$ curl localhost:8080/items/abc
invalid id
$ curl -X DELETE localhost:8080/items/1
Method Not Allowed

Method-and-wildcard patterns require go 1.22 or later in your go.mod; with an older version line, "GET /items/{id}" is treated as a literal path.

Common mistakes

  • Forgetting return after http.Error(...), so the handler carries on and writes a second response.
  • Setting headers after calling w.WriteHeader or w.Write — they are ignored because the headers have already been sent.
  • Using http.ListenAndServe with no timeouts for a public server. An http.Server with ReadTimeout / WriteTimeout protects against slow clients.
  • Modifying shared maps or counters from handlers without a mutex. Handlers run concurrently.
  • Registering "/" and expecting it to match only the home page. A pattern ending in / matches everything below it; use "GET /{$}" for the exact root path.

Practice

Add a DELETE /items/{id} route to the server above. It should respond with 204 No Content when the item existed and 404 when it didn't.

Show solution
func (s *store) deleteItem(w http.ResponseWriter, r *http.Request) {
    id, err := strconv.Atoi(r.PathValue("id"))
    if err != nil {
        http.Error(w, "invalid id", http.StatusBadRequest)
        return
    }

    s.mu.Lock()
    _, ok := s.items[id]
    delete(s.items, id)
    s.mu.Unlock()

    if !ok {
        http.Error(w, "item not found", http.StatusNotFound)
        return
    }
    w.WriteHeader(http.StatusNoContent)
}

// In main, next to the other routes:
// mux.HandleFunc("DELETE /items/{id}", s.deleteItem)