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.

18 min 8 sections

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

go
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)
	}
}
bash
go run . &
curl -i localhost:8080/hello
text
HTTP/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 Go

You 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:

go
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:

go
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:

go
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:

  1. w.Header() returns the header map. Set headers first.
  2. w.WriteHeader(code) sends the status line and headers. It can only happen once.
  3. w.Write(b) sends body bytes. If you have not called WriteHeader, the first Write calls WriteHeader(http.StatusOK) for you.
go
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:

go
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 methodWhat it holds
r.Method"GET", "POST", …
r.URL.PathThe path, already decoded: /notes/7
r.URL.Query()Parsed query string as url.Values
r.Header.Get("Accept")One header value (case-insensitive)
r.BodyAn 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:

go
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.

PatternMatches
GET /notesGET or HEAD requests for exactly /notes
POST /notesPOST /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 GET also matches HEAD. A pattern without a method matches every method.
  • A wildcard {name} matches exactly one whole path segment; /b_{id} is invalid. Read it with r.PathValue("name"), which returns the decoded value (%20 becomes 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 /static when only /static/ is registered gets a 301 redirect 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:

go
mux.HandleFunc("/static/", serveStatic)
mux.HandleFunc("GET /", serveHome)
text
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 pattern

GET /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.

text
$ 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:

go
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)
}
FieldLimitsIf unset
ReadHeaderTimeoutTime to read the request headersFalls back to ReadTimeout
ReadTimeoutTime to read the entire request, body includedNo limit
WriteTimeoutTime from the end of reading the headers until the response is writtenNo limit
IdleTimeoutHow long a keep-alive connection may sit idle between requestsFalls 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 /notes lists notes as id: text lines, ordered by ID.
  • POST /notes creates a note from the request body (max 1 KiB, must not be blank) and replies 201 with a Location header.
  • GET /notes/{id} returns one note's text, 404 if 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
go
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)
	}
}
bash
curl -i -d 'buy milk' localhost:8080/notes
curl -d 'call mum' localhost:8080/notes
curl localhost:8080/notes
curl -i localhost:8080/notes/3
text
HTTP/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 found

A 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; HandlerFunc adapts 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, and http.Error must be followed by return.
  • Limit request bodies with http.MaxBytesReader before 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.ListenAndServe defaults: configure an http.Server with at least ReadHeaderTimeout, plus read, write and idle timeouts.
  • Handlers run concurrently, so shared state needs a lock.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.A handler calls w.WriteHeader(http.StatusCreated) and then w.Header().Set("Location", "/notes/1"). What does the client receive?

  2. 2.The only pattern registered for /items/{id} is "GET /items/{id}". What happens on DELETE /items/3?

  3. 3.You register both "GET /posts/{id}" and "GET /posts/latest". Which handler serves GET /posts/latest?

  4. 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.