# HTTP servers with net/http

> Build HTTP servers with the standard library: write handlers, route with ServeMux patterns and configure production-safe timeouts.

Backend with Go roadmap · Building APIs · 18 min read · https://levelcode.org/roadmaps/go-backend/net-http-servers

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)
}
```

> [!WARNING]
> Forgetting `return` after `http.Error` is the most common `net/http` bug. The handler keeps running, appends more text to the body, and may even perform the action the error was meant to prevent.

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

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

| 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 `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)
}
```

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

> [!IMPORTANT]
> Because every request runs in its own goroutine, handlers run **concurrently**. Any state shared between them, such as a map on your `app` struct, must be protected with a mutex. Run your server under `go run -race .` while developing.

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

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

## Quiz

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

   A. 201 with the Location header
   B. 201 without the Location header
   C. 500, because headers were set in the wrong order
   D. A compile error

   **Answer: B.** WriteHeader sends the status line and the headers as they are at that moment. Later header changes are silently ignored. Always set headers before WriteHeader or the first Write.

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

   A. 404 Not Found
   B. The GET handler runs, because the path matches
   C. 405 Method Not Allowed with Allow: GET, HEAD
   D. The server panics

   **Answer: C.** When the path matches a pattern but the method does not, ServeMux responds 405 and lists the allowed methods. GET patterns also match HEAD, so both appear in Allow.

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

   A. The one for GET /posts/latest, because it is more specific
   B. Whichever was registered first
   C. Whichever was registered last
   D. Neither: the patterns conflict and registration panics

   **Answer: A.** /posts/latest matches a strict subset of the requests /posts/{id} matches, so it is more specific and wins regardless of registration order. Conflicts only arise when neither pattern is more specific than the other.

4. Which http.Server setting most directly protects against clients that open connections and send request headers extremely slowly?

   A. WriteTimeout
   B. IdleTimeout
   C. MaxHeaderBytes
   D. ReadHeaderTimeout

   **Answer: D.** ReadHeaderTimeout bounds how long the server waits for the complete request headers, closing slow (Slowloris-style) connections. MaxHeaderBytes limits size, not speed; WriteTimeout and IdleTimeout cover other phases.
