Step 08 of 24
HTTP servers with net/http
Build HTTP servers with the standard library: write handlers, route with ServeMux patterns and configure production-safe timeouts.
Go's standard library includes a production-grade HTTP server that many teams run with no framework on top. Since Go 1.22 its router understands methods and path wildcards, removing the main reason to reach for third-party routers. This lesson covers the Handler interface at the centre of net/http, how to read requests and write responses correctly, the ServeMux pattern syntax and its precedence rules, and the server timeouts every service needs.
The smallest useful server
package main
import (
"fmt"
"log/slog"
"net/http"
"os"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello from Go")
})
slog.Info("listening", "addr", ":8080")
if err := http.ListenAndServe(":8080", mux); err != nil {
slog.Error("server stopped", "err", err)
os.Exit(1)
}
}go run . &
curl -i localhost:8080/helloHTTP/1.1 200 OK
Date: Sun, 11 Oct 2026 09:00:00 GMT
Content-Length: 14
Content-Type: text/plain; charset=utf-8
Hello from GoYou never set the status or Content-Type; Go filled in defaults, as you will see shortly. ListenAndServe blocks, serving each request in its own goroutine, and only returns on failure, such as the port already being in use.
Handlers: one small interface
Everything in net/http revolves around one interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}Any type with that method can serve requests. Writing a whole type for each endpoint would be tedious, so the package also defines HandlerFunc, a function type that satisfies Handler by calling itself:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }mux.HandleFunc(pattern, fn) is shorthand for mux.Handle(pattern, http.HandlerFunc(fn)). The mux itself is also a Handler: its ServeHTTP picks a registered handler and calls it. That composability is what middleware builds on in the next lesson.
Real handlers need dependencies: a database, a logger, configuration. Idiomatically, handlers are methods on a struct holding those dependencies, rather than reaching for globals:
type app struct {
logger *slog.Logger
version string
}
func (a *app) handleVersion(w http.ResponseWriter, r *http.Request) {
a.logger.Info("version requested", "remote", r.RemoteAddr)
fmt.Fprintln(w, a.version)
}
func (a *app) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /version", a.handleVersion) // a method value is a func
return mux
}Writing responses
http.ResponseWriter has three methods, and they must be used in a fixed order:
w.Header()returns the header map. Set headers first.w.WriteHeader(code)sends the status line and headers. It can only happen once.w.Write(b)sends body bytes. If you have not calledWriteHeader, the firstWritecallsWriteHeader(http.StatusOK)for you.
func handleCreate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Location", "/notes/1")
w.WriteHeader(http.StatusCreated) // 201
fmt.Fprintln(w, "created note 1")
}Once the status line is sent, it is on the wire: headers set after WriteHeader or the first Write are silently ignored, and a second WriteHeader only logs http: superfluous response.WriteHeader call. If you never set Content-Type, Go sniffs the first 512 bytes of the body and guesses, which is how the hello example got text/plain. Set it explicitly for anything an API returns.
For error responses, http.Error(w, message, code) sets a plain-text content type, writes the status and the message. It does not stop your handler:
func handleGet(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return // without this, the code below runs and writes a second response
}
fmt.Fprintf(w, "note %s\n", id)
}Reading the request
*http.Request gives you everything the client sent:
| Field or method | What it holds |
|---|---|
r.Method | "GET", "POST", … |
r.URL.Path | The path, already decoded: /notes/7 |
r.URL.Query() | Parsed query string as url.Values |
r.Header.Get("Accept") | One header value (case-insensitive) |
r.Body | An io.ReadCloser streaming the request body |
r.PathValue("id") | A wildcard from the matched pattern (below) |
r.Context() | A context cancelled when the client disconnects |
The body is a stream, not a string. Read it with io.ReadAll, but never without a size limit, or one client can send gigabytes and exhaust your memory. http.MaxBytesReader caps it:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body too large or unreadable", http.StatusRequestEntityTooLarge)
return
}The server closes the request body for you after the handler returns. You will decode JSON bodies properly, including distinguishing those errors, in the JSON lesson.
ServeMux patterns
A pattern has the form [METHOD ][HOST]/[PATH]. All three parts are optional except the leading slash of the path.
| Pattern | Matches |
|---|---|
GET /notes | GET or HEAD requests for exactly /notes |
POST /notes | POST /notes only |
/notes/{id} | Any method, /notes/ plus one segment: /notes/7 |
GET /files/{path...} | /files/a/b/c.txt, with path = a/b/c.txt |
GET /static/ | /static/ and everything below it |
GET /{$} | Only / |
GET / | Every path: a catch-all |
api.example.com/ | Requests with that Host |
The rules behind the table:
- A pattern with a method matches only that method, except that
GETalso matchesHEAD. A pattern without a method matches every method. - A wildcard
{name}matches exactly one whole path segment;/b_{id}is invalid. Read it withr.PathValue("name"), which returns the decoded value (%20becomes a space) or""if the pattern has no such wildcard. A final{name...}matches the rest of the path. - A path ending in a slash matches the whole subtree. Requesting
/staticwhen only/static/is registered gets a301redirect to/static/. To match only the slash path itself, end the pattern with{$}.
Precedence and conflicts
When several patterns match a request, the most specific one wins: the pattern that matches a strict subset of the other's requests. GET /notes/latest beats GET /notes/{id} for /notes/latest, and GET /notes/{id} handles every other note. Registration order never matters.
If neither of two overlapping patterns is more specific, they conflict and registration panics at start-up, which is far better than ambiguous routing in production:
mux.HandleFunc("/static/", serveStatic)
mux.HandleFunc("GET /", serveHome)panic: pattern "GET /" (registered at main.go:12) conflicts with pattern "/static/" (registered at main.go:11):
GET / matches fewer methods than /static/, but has a more general path patternGET /static/app.css matches both, and each is more specific in a different dimension. Adding GET to the first pattern, or changing the second to GET /{$}, resolves it.
Two more behaviours come for free. If a path matches but the method does not, the mux replies 405 Method Not Allowed with an Allow header listing the methods that would work. If nothing matches, it replies 404 page not found.
$ curl -i -X DELETE localhost:8080/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
...http.Server and timeouts
http.ListenAndServe is convenient but creates a server with no timeouts at all. A client can open a connection and send one header byte every few seconds forever, holding a goroutine and a file descriptor. Enough of these (a "Slowloris" attack, or just a flaky mobile network) exhausts your server. Configure an http.Server explicitly:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil {
slog.Error("server stopped", "err", err)
os.Exit(1)
}| Field | Limits | If unset |
|---|---|---|
ReadHeaderTimeout | Time to read the request headers | Falls back to ReadTimeout |
ReadTimeout | Time to read the entire request, body included | No limit |
WriteTimeout | Time from the end of reading the headers until the response is written | No limit |
IdleTimeout | How long a keep-alive connection may sit idle between requests | Falls back to ReadTimeout |
ReadHeaderTimeout is the essential defence against slow clients. WriteTimeout effectively caps how long a handler can take: once it expires, writes fail and the client sees the connection drop. Set it above your slowest legitimate endpoint, and give streaming or long-poll endpoints their own per-request deadline with http.NewResponseController(w).SetWriteDeadline. Header size is capped too: MaxHeaderBytes defaults to 1 MiB.
Stopping the server cleanly, letting in-flight requests finish during a deploy, uses srv.Shutdown and is covered in the deployment lesson.
Practice
Build an in-memory notes service with plain-text bodies:
GET /noteslists notes asid: textlines, ordered by ID.POST /notescreates a note from the request body (max 1 KiB, must not be blank) and replies201with aLocationheader.GET /notes/{id}returns one note's text,404if it does not exist.
Use a struct with a mutex for storage, handler methods on that struct, and an http.Server with timeouts. Test it with curl.
Show solution
package main
import (
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"os"
"slices"
"strconv"
"strings"
"sync"
"time"
)
type store struct {
mu sync.Mutex
notes map[int]string
nextID int
}
func (s *store) list(w http.ResponseWriter, r *http.Request) {
var b strings.Builder
s.mu.Lock()
for _, id := range slices.Sorted(maps.Keys(s.notes)) {
fmt.Fprintf(&b, "%d: %s\n", id, s.notes[id])
}
s.mu.Unlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
io.WriteString(w, b.String())
}
func (s *store) create(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1024))
if err != nil {
http.Error(w, "note too large", http.StatusRequestEntityTooLarge)
return
}
text := strings.TrimSpace(string(body))
if text == "" {
http.Error(w, "note must not be blank", http.StatusBadRequest)
return
}
s.mu.Lock()
s.nextID++
id := s.nextID
s.notes[id] = text
s.mu.Unlock()
w.Header().Set("Location", fmt.Sprintf("/notes/%d", id))
w.WriteHeader(http.StatusCreated)
fmt.Fprintf(w, "%d: %s\n", id, text)
}
// lookupID parses the {id} wildcard; ok is false if it is not a number.
func lookupID(r *http.Request) (int, bool) {
id, err := strconv.Atoi(r.PathValue("id"))
return id, err == nil
}
func (s *store) get(w http.ResponseWriter, r *http.Request) {
id, ok := lookupID(r)
s.mu.Lock()
text, found := s.notes[id]
s.mu.Unlock()
if !ok || !found {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, text)
}
func main() {
s := &store{notes: make(map[int]string)}
mux := http.NewServeMux()
mux.HandleFunc("GET /notes", s.list)
mux.HandleFunc("POST /notes", s.create)
mux.HandleFunc("GET /notes/{id}", s.get)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
slog.Info("listening", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil {
slog.Error("server stopped", "err", err)
os.Exit(1)
}
}curl -i -d 'buy milk' localhost:8080/notes
curl -d 'call mum' localhost:8080/notes
curl localhost:8080/notes
curl -i localhost:8080/notes/3HTTP/1.1 201 Created
Location: /notes/1
...
1: buy milk
2: call mum
1: buy milk
2: call mum
HTTP/1.1 404 Not Found
...
404 page not foundA non-numeric ID such as /notes/abc is treated as "no such note", a choice the next lesson revisits. The mutex guards map access only, never network writes.
Key takeaways
- Everything is an
http.Handler;HandlerFuncadapts plain functions, and handler methods on a struct carry dependencies without globals. - Write responses in order: headers, then
WriteHeader, then body. Late headers are ignored, andhttp.Errormust be followed byreturn. - Limit request bodies with
http.MaxBytesReaderbefore reading them. - ServeMux patterns combine method, host and path wildcards (
{id},{path...},{$}); the most specific pattern wins and conflicts panic at registration. - Never ship
http.ListenAndServedefaults: configure anhttp.Serverwith at leastReadHeaderTimeout, plus read, write and idle timeouts. - Handlers run concurrently, so shared state needs a lock.
Check yourself
Quiz
0/4 correct · 4 left
1.A handler calls w.WriteHeader(http.StatusCreated) and then w.Header().Set("Location", "/notes/1"). What does the client receive?
2.The only pattern registered for /items/{id} is "GET /items/{id}". What happens on DELETE /items/3?
3.You register both "GET /posts/{id}" and "GET /posts/latest". Which handler serves GET /posts/latest?
4.Which http.Server setting most directly protects against clients that open connections and send request headers extremely slowly?
Finished reading?
Mark it complete to fill in your roadmap.