🏪 Analogy
An HTTP server is a shop counter. A handler is the clerk who serves one customer (request) and hands back a receipt (response). The mux is the greeter who reads the sign — /orders, /users — and points each customer to the right clerk. Go hires a fresh clerk (goroutine) per customer, so the queue never backs up.
Handlers all the way down
The whole server is built on one interface — http.Handler — with a single method ServeHTTP(w, r). http.HandlerFunc lets a plain function satisfy it, which is why you can hand a function to HandleFunc:
graph LR REQ["*http.Request"] --> MUX["ServeMux<br/>(route by method + path)"] MUX --> H["Handler.ServeHTTP(w, r)"] H --> W["http.ResponseWriter"] W --> RES["status + headers + body"]
Run a handler in-process
You don’t need a network to test handler logic — httptest drives a handler directly with a recorder. This is exactly how you’d unit-test it, and it runs anywhere (the go.dev sandbox has no real network):
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
// a handler reads the request and writes the response
func greet(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
name = "world"
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "hello, %s", name)
}
func main() {
// httptest runs the handler IN-PROCESS: no socket, no network, safe anywhere
req := httptest.NewRequest("GET", "/greet?name=Ada", nil)
rec := httptest.NewRecorder()
greet(rec, req) // call the handler like any function
res := rec.Result()
fmt.Println("status:", res.StatusCode) // 200
fmt.Println("type: ", res.Header.Get("Content-Type")) // text/plain
fmt.Println("body: ", rec.Body.String()) // hello, Ada
}
Routing with ServeMux
Since Go 1.22, the standard http.ServeMux matches on method and path pattern and supports path wildcards — so common routing no longer needs a third-party router. Register "GET /users/{id}" and read the wildcard with r.PathValue("id"):
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
// method + path pattern with a {id} wildcard (Go 1.22+)
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "get user %s", r.PathValue("id"))
})
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprint(w, "created")
})
// drive the mux in-process with a recorder
for _, tc := range []struct{ method, path string }{
{"GET", "/users/42"},
{"POST", "/users"},
{"DELETE", "/users/42"}, // no route → 405 Method Not Allowed
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(tc.method, tc.path, nil))
fmt.Printf("%s %-10s -> %d %q\n", tc.method, tc.path, rec.Code, rec.Body.String())
}
}
Reading the request body
For a JSON API, decode straight from r.Body with a json.Decoder, check the error, and validate. This handler echoes a decoded payload back as JSON — driven in-process by passing a body to httptest.NewRequest:
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
)
type CreateUser struct {
Name string "json:\"name\""
Age int "json:\"age\""
}
func createUser(w http.ResponseWriter, r *http.Request) {
var in CreateUser
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if in.Name == "" {
http.Error(w, "name required", http.StatusUnprocessableEntity)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]any{"id": 1, "name": in.Name})
}
func main() {
body := strings.NewReader("{\"name\":\"Ada\",\"age\":36}")
req := httptest.NewRequest("POST", "/users", body)
rec := httptest.NewRecorder()
createUser(rec, req)
fmt.Println("status:", rec.Code) // 201
fmt.Print("body: ", rec.Body.String()) // {"id":1,"name":"Ada"}
}
The ResponseWriter, in order
Write the response in the right sequence — headers, then status, then body:
w.Header().Set(...)— set any headers first.w.WriteHeader(code)— send the status line (optional; defaults to200on firstWrite).w.Write(...)/fmt.Fprintf(w, ...)— the body.
Once you’ve written the body you can’t change the status or headers — they’re already on the wire. Use http.Error(w, msg, code) for a clean error response.
A production server
Bare http.ListenAndServe(addr, nil) has no timeouts, so a slow client can hold a connection open forever (the Slowloris attack). In production, build an explicit http.Server:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // cap slow-header attacks
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe()) // run locally — needs a real network
Shut it down cleanly with srv.Shutdown(ctx) — see graceful shutdown.
Reference
| Task | API |
|---|---|
| Define a handler | func(w http.ResponseWriter, r *http.Request) |
| Adapt a func to Handler | http.HandlerFunc |
| Route by method+path | mux.HandleFunc("GET /users/{id}", h) |
| Read a path wildcard | r.PathValue("id") |
| Read a query param | r.URL.Query().Get("q") |
| Read a header | r.Header.Get("Authorization") |
| Decode a JSON body | json.NewDecoder(r.Body).Decode(&v) |
| Cap body size | http.MaxBytesReader(w, r.Body, n) |
| Set status / error | w.WriteHeader(code) / http.Error(w, msg, code) |
| Production server | &http.Server{...timeouts...} |
⚠️ Handlers run concurrently; write headers before the body
Every request gets its own goroutine, so any shared state a handler touches must be synchronized (a sync.Mutex or a channel) — the race detector will catch slips. The write order is strict: calling WriteHeader (or the first Write) freezes the status and headers, so set them first. And always give the server timeouts and bound request bodies — the defaults protect you from nothing.
See also
- HTTP client — the other half: making requests.
- routing & middleware — composing handlers with cross-cutting concerns.
- REST APIs — handler conventions for JSON resources.
- web security — security headers, body limits, and safe defaults.
- graceful shutdown — draining in-flight requests on exit.
Next: calling other services — the HTTP client.
Related topics
Calling services with net/http — http.Get vs a configured http.Client with timeouts, building requests and posting JSON, checking status, closing and draining the body, connection reuse via Transport, and context cancellation.
httpRouting & MiddlewareDispatch and cross-cutting concerns — the Go 1.22 ServeMux (method+path patterns, wildcards, precedence), path values, middleware as Handler-wrapping-Handler, chaining order, and recovery/logging middleware.
apisBuilding REST APIsJSON over HTTP done right — resources and methods, idempotency, decoding/validating requests and encoding responses, the status codes that matter, consistent error envelopes, and versioning.
Check your understanding
Score: 0 / 51. What is an http.Handler?
Handler is a one-method interface: ServeHTTP(ResponseWriter, *Request). http.HandlerFunc adapts a plain func to it, which is why you can pass functions to HandleFunc.
2. How does net/http handle concurrent requests?
http.Server.Serve accepts connections and runs every request handler in its own goroutine. So handlers must be safe for concurrent use — guard shared state with a mutex or a channel.
3. Why prefer a configured http.Server over http.ListenAndServe(addr, nil) in production?
The default server has no timeouts, so a slow client can hold a connection forever (Slowloris). Construct an http.Server with explicit timeouts and your own mux for real deployments.
4. In Go 1.22+, how do you register a handler for GET requests to /users/{id} and read the id?
Go 1.22's ServeMux understands method+pattern strings like "GET /users/{id}" and exposes wildcards via r.PathValue("id"). Method-based matching and path parameters are now built in — no external router needed for common cases.
5. What's the safe way to decode a JSON request body in a handler?
Decode straight from r.Body with a Decoder, always check the error (malformed JSON, wrong types), and validate the result. Wrap the body in http.MaxBytesReader to reject oversized payloads, and set a Content-Type check for strictness.
Comments
Sign in with GitHub to join the discussion.