← thecodex.expert · The Codex Family of Knowledge
Go

HTTP Servers

Nine lines of standard library code — no framework, no dependencies — is enough for a working HTTP server, which is part of why Go dominates cloud infrastructure tooling.

Go 1.26 net/http, zero deps Last verified:
Where this bites you

A handler increments a package-level counter on every hit — it's the obvious way to track how many times a route has been called. Under real traffic, with real concurrent requests, the final number comes out lower than the actual request count, with no error anywhere to explain it. That's because the page's own warning below ("handler code that touches shared state needs the same synchronization as any other concurrent Go code") was only prose until now — here it is as a real, measured race.

Gohttp_bug.go
var hitCount int // shared across every request's goroutine

func hello(w http.ResponseWriter, r *http.Request) {
	hitCount++ // no lock -- a data race under concurrent requests
	fmt.Fprintln(w, "hits:", hitCount)
}
// 1000 concurrent requests, then: fmt.Println("hitCount:", hitCount)
// hitCount: 997        -- lost increments, varies per run
//
// go run -race:
// WARNING: DATA RACE
// Write at 0x... by goroutine 8:  main.hello()
// Previous write at 0x... by goroutine 18: main.hello()
Canonical Definition

The net/http package implements a full HTTP/1.1 and HTTP/2 client and server without any third-party dependency. A handler is any value satisfying the http.Handler interface (one method: ServeHTTP), and http.HandleFunc lets an ordinary function serve as a handler directly. http.ListenAndServe starts the server and blocks, handling each incoming request concurrently in its own goroutine automatically.

A minimal server

A handler function takes an http.ResponseWriter to write the response and an *http.Request describing the incoming request.

Goserver.go
func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, %s!", r.URL.Query().Get("name"))
}

func main() {
    http.HandleFunc("/hello", hello)
    log.Fatal(http.ListenAndServe(":8080", nil))   // blocks; visit localhost:8080/hello?name=Go
}

Routing with net/http's ServeMux

Since Go 1.22 (2024), the standard library's router (http.ServeMux) natively supports method matching and path wildcards — patterns that previously required a third-party router package.

Gorouting.go
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)     // method + path wildcard (Go 1.22+)
mux.HandleFunc("POST /users", createUser)

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

func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")   // extracts {id} from the matched path
    fmt.Fprintf(w, "user %s", id)
}

Every request runs in its own goroutine

The server automatically handles each incoming request in a new goroutine, so a slow request doesn't block others — this is Go's concurrency model applied transparently at the framework level. It also means handler code that touches shared state (a cache, a counter) needs the same synchronization (sync.Mutex, channels) as any other concurrent Go code. A sync.Mutex fixes the counter in the callout above, but it has to wrap the entire read-and-write, not just the increment — reading the shared value again after unlocking reopens the exact same race on the read side:

Gohttp_fix.go
var (
	hitCount int
	mu       sync.Mutex
)

func hello(w http.ResponseWriter, r *http.Request) {
	mu.Lock()
	hitCount++
	n := hitCount // read it while still holding the lock
	mu.Unlock()
	fmt.Fprintln(w, "hits:", n) // now safe under concurrent requests
}
// hitCount: 1000       -- every run, go run -race reports nothing

Sources

1
The Go Authors. net/http package documentation, pkg.go.dev/net/http, and "Routing Enhancements for Go 1.22", go.dev/blog/routing-enhancements.