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.
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()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.
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.
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:
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