Go · lesson 23 of 25
HTTP Server in Go
A Go HTTP server run for real with curl: ServeMux patterns and the catch-all /, method routing, a JSON API, middleware, request context, graceful shutdown.
By SkillByExample · Updated
The standard library’s net/http is a production-grade server: Go’s own websites and many large services run on it without a framework. Each program below is a real server started in the background and driven with curl on this machine; the transcripts show exactly what came back. Date headers and timings will differ when you run them.
A first server, and the catch-all ”/”
A ServeMux maps URL patterns to handlers; ListenAndServe accepts connections and serves each request in its own goroutine.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "home page, you asked for %s\n", r.URL.Path)
})
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
fmt.Println("listening on :8081")
log.Fatal(http.ListenAndServe(":8081", mux))
}
$ go build -o server server.go
$ ./server &
listening on :8081
$ curl -s localhost:8081/health
ok
$ curl -s localhost:8081/
home page, you asked for /
$ curl -s localhost:8081/no/such/page
home page, you asked for /no/such/page
/no/such/page didn’t produce a 404: a pattern ending in / matches every path beneath it, and / is beneath everything. Use the pattern /{$} to match only the root. ListenAndServe only returns on failure, so it is wrapped in log.Fatal; the server runs until the process is stopped (the transcript stops it after the last curl).
Method and wildcard routing (Go 1.22+)
Patterns can name an HTTP method and capture path segments with {name}.
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "root only")
})
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "user %s\n", r.PathValue("id"))
})
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "file %s\n", r.PathValue("path"))
})
fmt.Println("listening on :8082")
log.Fatal(http.ListenAndServe(":8082", mux))
}
$ go build -o server server.go
$ ./server &
listening on :8082
$ curl -s localhost:8082/users/42
user 42
$ curl -s localhost:8082/files/docs/2024/report.pdf
file docs/2024/report.pdf
$ curl -s localhost:8082/nope
404 page not found
$ curl -s -i -X DELETE localhost:8082/users/42
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Date: Tue, 06 Oct 2026 18:48:10 GMT
Content-Length: 19
Method Not Allowed
{id} matches one segment and {path...} the rest of the path, read back with r.PathValue. With /{$} for the root, unknown paths now get a real 404. A DELETE on a path that only has a GET route gets 405 Method Not Allowed and an Allow header listing what is allowed — the mux generates that for you. Registering GET also registers HEAD.
A JSON API
A small in-memory store with POST to create and GET to list, returning JSON and proper status codes.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"sync"
)
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
}
type store struct {
mu sync.Mutex
todos []Todo
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func (s *store) create(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap
var in struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Title == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title required"})
return
}
s.mu.Lock() // handlers run concurrently
t := Todo{ID: len(s.todos) + 1, Title: in.Title}
s.todos = append(s.todos, t)
s.mu.Unlock()
writeJSON(w, http.StatusCreated, t)
}
func (s *store) list(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
writeJSON(w, http.StatusOK, s.todos)
}
func main() {
s := &store{todos: []Todo{}}
mux := http.NewServeMux()
mux.HandleFunc("POST /todos", s.create)
mux.HandleFunc("GET /todos", s.list)
fmt.Println("listening on :8083")
log.Fatal(http.ListenAndServe(":8083", mux))
}
$ go build -o server server.go
$ ./server &
listening on :8083
$ curl -s localhost:8083/todos
[]
$ curl -s -i -X POST localhost:8083/todos -d '{"title":"write lesson"}'
HTTP/1.1 201 Created
Content-Type: application/json
Date: Tue, 06 Oct 2026 18:48:11 GMT
Content-Length: 32
{"id":1,"title":"write lesson"}
$ curl -s -X POST localhost:8083/todos -d '{"title":"run it"}'
{"id":2,"title":"run it"}
$ curl -s -w '%{http_code}\n' -X POST localhost:8083/todos -d '{"titel":"typo"}'
{"error":"title required"}
400
$ curl -s localhost:8083/todos
[{"id":1,"title":"write lesson"},{"id":2,"title":"run it"}]
writeJSON sets the header before WriteHeader; headers set afterwards are ignored, because the status line and headers have already been sent. Initialising todos to an empty slice makes the first GET return [] rather than null. The misspelled key decoded into an empty Title and was rejected with 400. The mutex is required: the server runs every request on its own goroutine, so two POSTs can arrive at once.
Middleware: logging with the status code
http.ResponseWriter doesn’t expose the status a handler wrote, so logging middleware wraps it.
package main
import (
"log"
"net/http"
"os"
"time"
)
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
log.Printf("%s %s %d %v", r.Method, r.URL.Path, sw.status, time.Since(start).Round(time.Microsecond))
})
}
func main() {
log.SetOutput(os.Stderr)
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("hi\n")) })
log.Fatal(http.ListenAndServe(":8084", logRequests(mux)))
}
$ go build -o server server.go
$ ./server 2> server.log &
$ curl -s localhost:8084/hello
hi
$ curl -s localhost:8084/missing
404 page not found
$ cut -c21- server.log
GET /hello 200 7µs
GET /missing 404 26µs
The server’s log went to server.log, printed after the requests (cut drops the date and time stamp). The default status is 200 because a handler that never calls WriteHeader sends 200 on its first Write; the mux’s 404 went through the wrapper’s WriteHeader and was recorded. Embedding http.ResponseWriter in statusWriter promotes all its other methods, so the wrapper still satisfies the interface.
Request context: noticing a client that left
r.Context() is cancelled when the client disconnects, so long-running handlers can stop early.
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func report(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(3 * time.Second): // stand-in for a slow query
fmt.Fprintln(w, "report ready")
case <-r.Context().Done():
log.Printf("client gave up: %v", r.Context().Err())
}
}
func main() {
http.HandleFunc("GET /report", report)
log.Fatal(http.ListenAndServe(":8085", nil))
}
$ go build -o server server.go
$ ./server 2> server.log &
$ curl -s --max-time 1 localhost:8085/report; echo "curl exit code $?"
curl exit code 28
$ cut -c21- server.log
client gave up: context canceled
curl gave up after one second (exit code 28 is curl’s timeout), the server saw the connection close, cancelled the request’s context, and the handler stopped instead of finishing three seconds of useless work. Pass r.Context() to database/sql queries and outgoing http.NewRequestWithContext calls and they get the same cancellation for free. Passing nil as the handler uses http.DefaultServeMux, which http.HandleFunc registers on — fine for small programs, but an explicit mux avoids global state.
Graceful shutdown
srv.Shutdown stops accepting new connections and waits for in-flight requests to finish.
package main
import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
log.Print("slow request started")
time.Sleep(time.Second) // simulated work, still in flight at shutdown
fmt.Fprintln(w, "slow request finished")
})
srv := &http.Server{Addr: ":8086", Handler: mux, ReadHeaderTimeout: 5 * time.Second}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
<-ctx.Done() // wait for a signal
log.Print("signal received, shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Print("forced shutdown: ", err)
}
log.Print("bye")
}
$ go build -o server server.go
$ ./server 2> server.log &
$ curl -s localhost:8086/slow &
$ kill -TERM %1
$ wait
slow request finished
$ cut -c21- server.log
slow request started
signal received, shutting down
bye
The SIGTERM (what Docker and Kubernetes send on stop) arrived while /slow was still running. Shutdown let it finish — curl still received its response — and only then did main log bye and exit. Without this, killing the process would cut the request off mid-response. ReadHeaderTimeout is set because the zero-value http.Server has no timeouts at all, which lets slow clients hold connections open indefinitely.
Practice
1. Add DELETE /todos/{id} to the JSON API that returns 204 No Content when it removes a todo and 404 otherwise. Test it with httptest instead of a running server.
Solution
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"sync"
)
type store struct {
mu sync.Mutex
todos map[int]string
}
func (s *store) remove(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.todos[id]; err != nil || !ok {
http.NotFound(w, r)
return
}
delete(s.todos, id)
w.WriteHeader(http.StatusNoContent)
}
func main() {
s := &store{todos: map[int]string{1: "write lesson"}}
mux := http.NewServeMux()
mux.HandleFunc("DELETE /todos/{id}", s.remove)
for _, path := range []string{"/todos/1", "/todos/1", "/todos/abc"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("DELETE", path, nil))
fmt.Println(path, rec.Code)
}
}
$ go run p1.go
/todos/1 204
/todos/1 404
/todos/abc 404
The second delete finds nothing and gets 404; a non-numeric id is treated the same way. Calling mux.ServeHTTP directly runs the real routing, PathValue included, without a network.
2. Start the basic server’s mux with httptest.NewServer and fetch /health with http.Get, printing the status and body.
Solution
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
srv := httptest.NewServer(mux) // real server on a random local port
defer srv.Close()
resp, err := http.Get(srv.URL + "/health")
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
fmt.Printf("%s %q\n", resp.Status, body)
}
$ go run p2.go
200 OK "ok\n"
Unlike httptest.NewRecorder, NewServer goes through a real TCP connection, so it also tests things like headers set by the server and client timeouts.
3. Write middleware that adds an X-Request-ID header (a counter is fine) to every response, and check it with curl -i.
Solution
package main
import (
"fmt"
"log"
"net/http"
"strconv"
"sync/atomic"
)
var nextID atomic.Int64
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Request-ID", strconv.FormatInt(nextID.Add(1), 10))
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hi") })
log.Fatal(http.ListenAndServe(":8087", requestID(mux)))
}
$ go build -o server server.go
$ ./server &
$ curl -s -i localhost:8087/ | grep -i -e x-request-id -e '^hi'
X-Request-Id: 1
hi
$ curl -s -i localhost:8087/ | grep -i -e x-request-id -e '^hi'
X-Request-Id: 2
hi
The header is set before calling next, while headers can still be changed. Each request gets the next number, so a fresh server answers with 1, then 2. atomic.Int64 keeps the counter safe across concurrent requests.