net/http Clients and Servers

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. The standard library alone is enough to write a production HTTP client and a production HTTP server in Go: net/http has a connection-pooling client, an HTTP/1.1 and HTTP/2 server, a router with method and wildcard patterns since Go 1.22, protection against cross-site request forgery since Go 1.25, a reverse proxy, static file serving and a complete test toolkit. This page covers all of it, with the settings that the defaults leave dangerous (no timeouts, no body limits) set deliberately, and ends with a short tour of the layer below: raw TCP with net, and the net/netip and net/url helper packages.

It builds on Context (every request carries one and every client call accepts one), Interfaces (http.Handler is the interface that Go web code is built around), Functions, Closures and Defer (middleware is closures returning closures) and Error Handling (errors.As on *url.Error and *http.MaxBytesError). The neighbours are encoding/json (the wire format of the shelf API), Structured Logging with slog (request logging), Time and Timers, database/sql (the layer behind the handlers), I/O, Files and the Filesystem (io.Reader, fs.FS), Testing and Benchmarks, Fuzzing and synctest, and Crypto and Security (TLS configuration, html/template and other hardening). On this site, the same problems in other stacks are covered by Axum (see its graceful shutdown and request limits and timeouts pages for the Rust equivalents), NestJS security headers, CORS and CSRF and the Rust async/await and futures page.

Every example was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output. Servers in the examples listen on 127.0.0.1 with a system-chosen port (httptest.NewServer, or net.Listen("tcp", "127.0.0.1:0")) and are short-lived, and the examples that depend on timing use large safety margins and print only facts that do not vary between runs. The go.mod of the example module is:

module example.com/shelf

go 1.27

The running scenario is shelf, the library catalogue shared by the whole Go Reference: the catalog package has Book, Author, ISBN (a validated defined type), a Store interface and the mutex-guarded MemStore, and cmd/shelfd exposes it as a JSON API. The catalog package is not repeated here; the code below uses it exactly as described on the Interfaces page.

The package at a glance

net/http has two halves that share the Request, Response and Header types. The client half sends requests; the server half receives them and calls your code. Almost everything is an interface or a function type, so almost everything can be wrapped, faked and tested without a network.

Name Role

http.Client

Sends requests with policy: timeout, redirects, cookies. Safe for concurrent use; create one and reuse it.

http.Transport

The http.RoundTripper that really does the work: dials, TLS, HTTP/1.1 and HTTP/2, the connection pool, proxies.

http.Request

An outgoing request (client) or an incoming one (server). Carries a context.Context.

http.Response

What the client gets back. Body must always be closed.

http.Handler

ServeHTTP(ResponseWriter, *Request): the server-side interface. http.HandlerFunc adapts a function.

http.ServeMux

The built-in router (patterns with methods and wildcards since Go 1.22). It is itself an http.Handler.

http.Server

Listens, accepts connections, enforces timeouts and calls the handler once per request, each in its own goroutine.

http.ResponseController

Per-response control from inside a handler: flush, deadlines, hijack, full duplex (Go 1.20).

http.CrossOriginProtection

CSRF defence based on browser fetch metadata (Go 1.25).

net/http/httptest

NewRecorder, NewRequest, NewServer and, since Go 1.27, NewTestServer for tests.

net/http/httputil

ReverseProxy and request/response dumps.

net/http/httptrace

Hooks into the client’s connection life cycle (DNS, connect, TLS, first byte).

net/http/cookiejar

An in-memory cookie jar for http.Client.

A request on the wire

HTTP/1.1 is a text protocol: a request line, headers, an empty line and an optional body, answered by a status line, headers, an empty line and an optional body (RFC 9110 defines the semantics, RFC 9112 the HTTP/1.1 syntax). HTTP/2 carries the same semantics in binary frames, and net/http hides the difference: the same Handler serves both. This is the exchange of a real curl -i against the shelfd program that is built later on this page (the Date header is omitted):

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 70

[{"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}]

Four details matter for the rest of the page. The status code belongs to the response, and an HTTP error status such as 404 is a successful exchange from the client library’s point of view (a transport failure is an error; a 404 is not). Content-Length and Content-Type tell the peer how to read the body. X-Content-Type-Options: nosniff is added by http.Error so that a browser does not guess a different type for an error body. And the method has a meaning that routers and security code rely on: GET, HEAD and OPTIONS are safe (they must not change state), PUT and DELETE are idempotent (repeating them has the same effect), and POST is neither. Retry logic and CSRF protection both depend on that classification.

The client

Never use the default client in production

http.Get, http.Post and http.DefaultClient are conveniences for scripts and tests. http.DefaultClient is the zero http.Client: it has no timeout. A server that accepts the connection and then never answers keeps the calling goroutine, its connection and its memory alive forever, and in a service that multiplies by the request rate. Every production client sets a timeout. Client.Timeout bounds the whole exchange: dialling, TLS, sending the request, following redirects and reading the response body (the timer keeps running after Do returns and interrupts reads of Response.Body).

package main

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/http/httptest"
	"net/url"
	"time"
)

func main() {
	// A server that never answers within the test.
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-r.Context().Done():
		case <-time.After(5 * time.Second):
		}
	}))
	defer srv.Close()

	client := &http.Client{Timeout: 150 * time.Millisecond}
	start := time.Now()
	_, err := client.Get(srv.URL + "/slow")

	var uerr *url.Error
	if errors.As(err, &uerr) {
		fmt.Println("op:", uerr.Op)
		fmt.Println("err:", uerr.Err)
		fmt.Println("timeout:", uerr.Timeout())
	}
	var nerr net.Error
	fmt.Println("net.Error:", errors.As(err, &nerr) && nerr.Timeout())
	fmt.Println("deadline exceeded:", errors.Is(err, context.DeadlineExceeded))
	fmt.Println("returned quickly:", time.Since(start) < 2*time.Second)
}
op: Get
err: context deadline exceeded (Client.Timeout exceeded while awaiting headers)
timeout: true
net.Error: true
deadline exceeded: true
returned quickly: true

Every error returned by Client.Do, Get, Post and friends has the dynamic type *url.Error; Op is the method name, URL the request URL, Err the cause, and Timeout() reports whether it was a timeout. A client timeout satisfies errors.Is(err, context.DeadlineExceeded) and net.Error.Timeout(), so the error handling pattern from Error Handling works unchanged. (The error message also contains the full URL, which is why this example prints the parts instead of the whole message: the port differs on every run, and in real programs a URL with credentials in the query string would end up in your logs.)

There are several independent time limits, and they answer different questions:

Limit Covers Where it is set

Client.Timeout

The whole call including redirects and reading the body.

http.Client

The request context

The same, but per request and cancellable by the caller (deadline, cancel, parent cancellation).

http.NewRequestWithContext

Dial timeout

Establishing the TCP connection.

net.Dialer.Timeout used as Transport.DialContext

TLSHandshakeTimeout

The TLS handshake.

http.Transport

ResponseHeaderTimeout

From the end of the request to the response headers (not the body).

http.Transport

ExpectContinueTimeout

Waiting for 100 Continue after sending Expect: 100-continue.

http.Transport

IdleConnTimeout

How long an unused pooled connection is kept.

http.Transport

A request context and Client.Timeout combine: whichever expires first ends the call. Prefer a context per call (the caller knows the budget) plus a conservative Client.Timeout as the safety net. The context rules, including why a context is the first parameter of the function that does the call and never a struct field, are on the Context page.

A request with a context, and what to do with the response

The pattern for every call is the same: build the request with a context, set headers, Do, check the error, close the body, check the status code, then read a bounded amount of the body.

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"time"

	"example.com/shelf/catalog"
)

// StatusError reports an unexpected HTTP status.
type StatusError struct {
	Code int
	Body string
}

func (e *StatusError) Error() string {
	return fmt.Sprintf("unexpected status %d: %s", e.Code, e.Body)
}

// getBook fetches one book from the shelf API.
func getBook(ctx context.Context, c *http.Client, base string, isbn catalog.ISBN) (catalog.Book, error) {
	var b catalog.Book
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/books/"+string(isbn), nil)
	if err != nil {
		return b, err
	}
	req.Header.Set("Accept", "application/json")

	resp, err := c.Do(req)
	if err != nil {
		return b, err
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		msg, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
		return b, &StatusError{Code: resp.StatusCode, Body: string(msg)}
	}
	// Never trust the size of a remote body.
	err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&b)
	return b, err
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /books/{isbn}", func(w http.ResponseWriter, r *http.Request) {
		if r.PathValue("isbn") != "9780441172719" {
			http.Error(w, "no such book", http.StatusNotFound)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		_ = json.NewEncoder(w).Encode(catalog.Book{
			ISBN:    "9780441172719",
			Title:   "Dune",
			Authors: []catalog.Author{{Name: "Frank Herbert"}},
		})
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	client := &http.Client{Timeout: 5 * time.Second}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	b, err := getBook(ctx, client, srv.URL, "9780441172719")
	fmt.Printf("%+v %v\n", b, err)

	_, err = getBook(ctx, client, srv.URL, "9780000000002")
	var se *StatusError
	fmt.Println(errors.As(err, &se), err)
}
{ISBN:9780441172719 Title:Dune Authors:[{Name:Frank Herbert}]} <nil>
true unexpected status 404: no such book
  • http.NewRequestWithContext(ctx, method, url, body) is the constructor to use. http.NewRequest is the same with context.Background(); there is no reason to prefer it in code that has a context. Method names are the constants http.MethodGet, http.MethodPost and so on.

  • A status code is not an error. Do returns a nil error for 404 and 500 responses. The helper above turns an unexpected status into a *StatusError that callers can match with errors.As, and reads at most 512 bytes of the body for the message.

  • defer resp.Body.Close() goes after the error check. If Do returned an error, resp is nil and the deferred call would panic with a nil pointer dereference. go vet has an analyzer, httpresponse, for exactly this mistake:

    package main
    
    import (
    	"fmt"
    	"net/http"
    )
    
    func main() {
    	resp, err := http.Get("http://127.0.0.1:1/")
    	defer resp.Body.Close()
    	if err != nil {
    		fmt.Println(err)
    		return
    	}
    	fmt.Println(resp.Status)
    }
    main.go:10:8: using resp before checking for errors
  • Closing the body is not optional. It releases the connection back to the pool. An HTTP/1.1 connection can be reused only after the response body has been read to the end (or closed): the documentation warns that a body not read to EOF and closed may keep the transport from reusing the connection, which defeats pooling. Go 1.27 changed that: closing an HTTP/1 Response.Body now drains unread content up to a conservative limit so the connection can be reused. A body larger than the limit still costs a connection, so reading the part you need and closing is correct, and io.Copy(io.Discard, io.LimitReader(resp.Body, n)) before Close remains a valid way to make the intent explicit.

  • Bound what you read. A remote server controls the size of its response. io.LimitReader (or http.MaxBytesReader on the server side) keeps a hostile or buggy peer from exhausting memory. The decoder in the example reads at most one mebibyte.

  • Accept and Content-Type are the caller’s job. The client sets only a few headers itself (User-Agent, Accept-Encoding: gzip with transparent decompression, Content-Length); Header.Set canonicalises the key (content-type becomes Content-Type).

  • Sending a body. http.NewRequestWithContext recognises *bytes.Reader, *bytes.Buffer and *strings.Reader, sets ContentLength and fills Request.GetBody, which lets the client replay the body on a redirect or after a connection failure. With any other reader (a pipe, an *os.File wrapped in another reader) the length is unknown and the body is sent chunked and cannot be replayed. http.NoBody marks an explicitly empty body, http.PostForm sends a url.Values as application/x-www-form-urlencoded, and for JSON you marshal into a buffer and set Content-Type yourself (see encoding/json).

Transport, connection pooling and DialContext

http.Client is policy; http.Transport is machinery and holds the connection pool. Two consequences follow. First, reuse one Client (and its Transport) for all calls to the same service: a new client per request defeats connection reuse, leaks idle connections and repeats TLS handshakes. Second, the settings that matter most for a service that calls other services are on the transport. Start from a clone of http.DefaultTransport (a fresh &http.Transport{} loses its proxy-from-environment setting, its dialer and its HTTP/2 enablement) and change what you need:

package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"net/http/httptrace"
	"time"
)

// newClient returns a client for a service we call often.
func newClient() *http.Client {
	tr := http.DefaultTransport.(*http.Transport).Clone()
	tr.DialContext = (&net.Dialer{
		Timeout:   3 * time.Second, // TCP connect
		KeepAlive: 30 * time.Second,
	}).DialContext
	tr.TLSHandshakeTimeout = 3 * time.Second
	tr.ResponseHeaderTimeout = 5 * time.Second
	tr.IdleConnTimeout = 90 * time.Second
	tr.MaxIdleConns = 100
	tr.MaxIdleConnsPerHost = 10 // the default of 2 is too low for a busy client
	return &http.Client{Transport: tr, Timeout: 10 * time.Second}
}

func main() {
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprint(w, "ok")
	}))
	defer srv.Close()

	c := newClient()
	for i := 1; i <= 2; i++ {
		var reused bool
		trace := &httptrace.ClientTrace{
			GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused },
		}
		req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
		req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
		resp, err := c.Do(req)
		if err != nil {
			fmt.Println(err)
			return
		}
		_, _ = io.Copy(io.Discard, resp.Body)
		resp.Body.Close()
		fmt.Printf("request %d: connection reused: %t\n", i, reused)
	}
	tr := c.Transport.(*http.Transport)
	fmt.Println("MaxIdleConnsPerHost:", tr.MaxIdleConnsPerHost)
	fmt.Println("default MaxIdleConnsPerHost:", http.DefaultMaxIdleConnsPerHost)
}
request 1: connection reused: false
request 2: connection reused: true
MaxIdleConnsPerHost: 10
default MaxIdleConnsPerHost: 2
  • DialContext is the hook for everything below HTTP: timeouts, keep-alive probes, a custom resolver (net.Dialer.Resolver), binding to a local address, dialling a Unix socket. The old Transport.Dial field (the one used in older tutorials) has no context and is deprecated.

  • MaxIdleConnsPerHost defaults to http.DefaultMaxIdleConnsPerHost, which is 2. A service that makes many concurrent calls to one backend keeps opening and closing connections beyond the second one; raise it to the concurrency you need. MaxConnsPerHost (default unlimited) caps the total, idle plus in use, and makes callers wait.

  • ForceAttemptHTTP2 is true in DefaultTransport. A custom Transport that sets DialContext or TLSClientConfig disables automatic HTTP/2 unless it sets this field again; cloning the default keeps it.

  • Proxy: http.ProxyFromEnvironment honours HTTP_PROXY, HTTPS_PROXY and NO_PROXY; set Proxy to nil for none or to your own function for per-URL decisions.

  • Client.CloseIdleConnections() (and Transport.CloseIdleConnections()) drop pooled connections, which matters in tests and at shutdown.

  • net/http/httptrace attaches a ClientTrace to a request context. The example uses GotConn to show that the second request reused the first connection; DNSStart, ConnectDone, TLSHandshakeDone and GotFirstResponseByte give a latency breakdown when you are debugging a slow call.

  • Transport.NewClientConn (Go 1.26) returns one client connection for callers that must manage connections themselves. Most programs should keep using RoundTrip through a Client, which manages the pool.

Redirects and sensitive headers

By default the client follows up to 10 redirects and then fails with stopped after 10 redirects. 301, 302 and 303 turn a POST into a GET without a body; 307 and 308 keep the method and replay the body through GetBody. When a redirect leads to a different domain, the client drops Authorization, WWW-Authenticate and Cookie headers, so a token is not forwarded to a third party. CheckRedirect replaces the policy; returning http.ErrUseLastResponse hands back the redirect response itself:

package main

import (
	"errors"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /old", func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/new", http.StatusMovedPermanently)
	})
	mux.HandleFunc("GET /new", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "you are here")
	})
	mux.HandleFunc("GET /loop", func(w http.ResponseWriter, r *http.Request) {
		http.Redirect(w, r, "/loop", http.StatusFound)
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	// 1. The default client follows up to 10 redirects.
	resp, err := http.Get(srv.URL + "/old")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("followed:", resp.StatusCode, resp.Request.URL.Path)
	resp.Body.Close()

	// 2. Do not follow: inspect the redirect itself.
	noFollow := &http.Client{
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			return http.ErrUseLastResponse
		},
	}
	resp, err = noFollow.Get(srv.URL + "/old")
	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("not followed:", resp.StatusCode, resp.Header.Get("Location"))
	resp.Body.Close()

	// 3. A loop stops after 10 redirects.
	_, err = http.Get(srv.URL + "/loop")
	var uerr *url.Error
	fmt.Println("loop:", errors.As(err, &uerr), uerr.Err)
}
followed: 200 /new
not followed: 301 /new
loop: true stopped after 10 redirects

Cookies

A nil Client.Jar means cookies are neither stored nor sent. net/http/cookiejar provides an in-memory jar that applies the usual domain, path, Secure and expiry rules and is consulted for every redirect hop; the cookie example in Forms and cookies below shows a client with a jar logging in and being recognised on the next request. Cookie.Quoted and Request.CookiesNamed (Go 1.23) and ParseCookie and ParseSetCookie (Go 1.23) cover the less common cases. Since Go 1.26 a client with a jar also uses and stores cookies against the host from Request.Host when it is set, not only the connection address.

Retrying

The standard library does not retry. Retrying is a policy decision: only idempotent requests are safe to repeat blindly (GET, HEAD, PUT, DELETE; a POST needs an idempotency key), only transient statuses are worth repeating (429, 502, 503, 504) and the wait must grow and be bounded by the caller’s context. The helper below does the minimum correctly, including draining and closing each failed response and honouring Retry-After; for the same ideas as configuration in other stacks see Resilience4j retry and time limiter.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strconv"
	"sync/atomic"
	"time"
)

// getWithRetry retries an idempotent GET on 429, 502, 503 and 504.
// It honours Retry-After (seconds) and stops as soon as ctx is done.
func getWithRetry(ctx context.Context, c *http.Client, url string, attempts int) (*http.Response, error) {
	backoff := 10 * time.Millisecond
	for attempt := 1; ; attempt++ {
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
		if err != nil {
			return nil, err
		}
		resp, err := c.Do(req)
		if err == nil && !retryable(resp.StatusCode) {
			return resp, nil // the caller closes the body
		}
		wait := backoff
		if err == nil {
			if s, perr := strconv.Atoi(resp.Header.Get("Retry-After")); perr == nil {
				wait = min(time.Duration(s)*time.Second, maxWait)
			}
			io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
			resp.Body.Close()
		}
		if attempt == attempts {
			if err == nil {
				err = fmt.Errorf("giving up after %d attempts: status %d", attempt, resp.StatusCode)
			}
			return nil, err
		}
		select {
		case <-time.After(wait):
		case <-ctx.Done():
			return nil, context.Cause(ctx)
		}
		backoff *= 2
	}
}

// maxWait caps what a server can ask for; use a few seconds in production.
const maxWait = 30 * time.Millisecond

func retryable(code int) bool {
	switch code {
	case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
		return true
	}
	return false
}

func main() {
	var calls atomic.Int32
	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if calls.Add(1) < 3 {
			w.Header().Set("Retry-After", "1")
			http.Error(w, "busy", http.StatusServiceUnavailable)
			return
		}
		fmt.Fprintln(w, "finally")
	}))
	defer srv.Close()

	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	resp, err := getWithRetry(ctx, srv.Client(), srv.URL, 5)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	fmt.Printf("%d after %d calls: %s", resp.StatusCode, calls.Load(), b)
}
200 after 3 calls: finally

HTTP/2 and protocol selection

Over TLS, the client and the server negotiate HTTP/2 through ALPN automatically, with no code. Go 1.24 added the Protocols field to http.Server and http.Transport (a small set type with SetHTTP1, SetHTTP2 and SetUnencryptedHTTP2) to make the choice explicit, and HTTP2 (*http.HTTP2Config) to tune HTTP/2 settings such as stream limits and ping timeouts. Unencrypted HTTP/2 ("prior knowledge", RFC 9113 section 3.3; the Upgrade: h2c form is not supported) is useful behind a TLS-terminating proxy.

package main

import (
	"fmt"
	"io"
	"net"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "served over %s\n", r.Proto)
	})

	// One cleartext port speaking both HTTP/1.1 and HTTP/2 (prior knowledge).
	srv := &http.Server{Handler: mux, Protocols: new(http.Protocols)}
	srv.Protocols.SetHTTP1(true)
	srv.Protocols.SetUnencryptedHTTP2(true)
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	go srv.Serve(ln)
	defer srv.Close()

	for _, h2 := range []bool{false, true} {
		tr := &http.Transport{Protocols: new(http.Protocols)}
		if h2 {
			tr.Protocols.SetUnencryptedHTTP2(true) // HTTP/2 only, no HTTP/1
		} else {
			tr.Protocols.SetHTTP1(true)
		}
		resp, err := (&http.Client{Transport: tr}).Get("http://" + ln.Addr().String())
		if err != nil {
			fmt.Println(err)
			continue
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("client proto %s: %s", resp.Proto, b)
		tr.CloseIdleConnections()
	}
}
client proto HTTP/1.1: served over HTTP/1.1
client proto HTTP/2.0: served over HTTP/2.0

A server whose Protocols is nil speaks HTTP/1 and HTTP/2 over TLS and HTTP/1 over plain TCP. Setting a non-nil TLSNextProto map without an h2 entry is the older way of disabling HTTP/2 and still works; Protocols is the simpler tool. Go 1.27 adds RFC 9218 priority handling to the HTTP/2 server (disable it with Server.DisableClientPriority).

The server

Handlers

The whole server side rests on one interface:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Any value with that method is a handler: a struct holding its dependencies (a store, a logger, a template set), or a function converted with http.HandlerFunc, which is the standard bridge from the interfaces chapter (a function type can implement an interface). The server calls ServeHTTP once per request, on a goroutine of its own, so a handler may block and may run concurrently with itself: any state it shares must be safe for concurrent use (the catalog.MemStore guards its map with an RWMutex for this reason).

package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
)

// hello is a Handler with state: a struct with a ServeHTTP method.
type hello struct{ greeting string }

func (h hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	fmt.Fprintf(w, "%s, %s\n", h.greeting, r.URL.Query().Get("name"))
}

// late shows the order rule: headers, then WriteHeader, then the body.
func late(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "partial body") // implicit WriteHeader(200)
	w.Header().Set("X-Too-Late", "1")
	w.WriteHeader(http.StatusInternalServerError) // ignored, logged
}

func get(c *http.Client, url string) {
	resp, err := c.Get(url)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer resp.Body.Close()
	body, _ := io.ReadAll(resp.Body)
	fmt.Printf("%d %q too-late=%q ctype=%q\n", resp.StatusCode, body,
		resp.Header.Get("X-Too-Late"), resp.Header.Get("Content-Type"))
}

func main() {
	mux := http.NewServeMux()
	mux.Handle("GET /hello", hello{greeting: "Hello"})
	mux.HandleFunc("GET /late", late)
	mux.HandleFunc("GET /sniff", func(w http.ResponseWriter, r *http.Request) {
		io.WriteString(w, "<html><body>hi</body></html>") // no Content-Type set
	})

	var logs bytes.Buffer
	srv := httptest.NewUnstartedServer(mux)
	srv.Config.ErrorLog = log.New(&logs, "", 0)
	srv.Start()
	defer srv.Close()

	get(srv.Client(), srv.URL+"/hello?name=Ada")
	get(srv.Client(), srv.URL+"/late")
	get(srv.Client(), srv.URL+"/sniff")
	fmt.Print("server log: ", logs.String())
}
200 "Hello, Ada\n" too-late="" ctype="text/plain; charset=utf-8"
200 "partial body\n" too-late="" ctype="text/plain; charset=utf-8"
200 "<html><body>hi</body></html>" too-late="" ctype="text/html; charset=utf-8"
server log: http: superfluous response.WriteHeader call from main.late (main.go:24)

The response has an order, and ResponseWriter enforces it silently:

  1. Headers first. w.Header() returns the header map; changes made after the status line has been sent are ignored (the second request above shows X-Too-Late missing from the response).

  2. WriteHeader(code) once. It sends the status line and headers. If the handler never calls it, the first Write calls WriteHeader(http.StatusOK) for it. A second call is ignored and logged (superfluous response.WriteHeader call), as the server log line above shows. go vet does not catch this; it is a classic source of "my 500 was sent as a 200".

  3. The body. Write sends bytes. If no Content-Type was set, the server sniffs the first 512 bytes with http.DetectContentType (the third request: text/html; charset=utf-8). Set Content-Type explicitly for anything but trivial text, and for JSON always.

http.Error(w, msg, code) sets Content-Type: text/plain; charset=utf-8 and X-Content-Type-Options: nosniff, writes the status and the message, and is the right tool for plain-text failures; http.NotFound, http.Redirect and http.StatusText are its relatives. Use the http.Status… constants (http.StatusCreated, http.StatusNoContent) instead of numbers. The handler must not touch w or r after ServeHTTP returns, and it must not keep r.Body open: the server closes the request body for you.

If a handler panics, the server recovers, logs the stack trace to Server.ErrorLog and closes the connection (or resets the HTTP/2 stream); one bad request does not take the process down. Panicking with http.ErrAbortHandler aborts the response quietly without the log entry, which is how reverse proxies abandon a response mid-copy. Recovering in middleware (below) lets you turn the panic into a 500 and log it your way.

The request carries what the client sent:

Field or method Meaning

Method, URL, Proto

Request line. On a server, URL has only Path and RawQuery (no scheme or host).

Host

The Host header (or :authority in HTTP/2); used by host-specific mux patterns.

Header

Request headers, canonical-key map. r.Header.Get("Accept").

Body, ContentLength, GetBody

The body stream (never nil on a server, http.NoBody when empty) and its declared length (-1 if unknown).

URL.Query()

The query string parsed into url.Values.

PathValue(name), SetPathValue

Wildcard values captured by the mux (Go 1.22).

Pattern

The mux pattern that matched, for logging and metrics labels (Go 1.23).

ParseForm, FormValue, PostFormValue, Form, PostForm

URL-encoded forms and query parameters; ParseMultipartForm, FormFile, MultipartForm for uploads.

Cookie(name), Cookies(), CookiesNamed(name)

Cookies sent by the client.

Context(), WithContext(ctx), Clone(ctx)

The request’s context. It is cancelled when the client disconnects, when the HTTP/2 stream is reset and when ServeHTTP returns. WithContext makes a shallow copy; Clone a deep one.

RemoteAddr, TLS

The peer’s address (ip:port) and TLS state (nil over plain HTTP). Behind a proxy RemoteAddr is the proxy.

The request context deserves a sentence of its own: it is how a handler learns that the client has gone away, and it is the parent of every context the handler derives for database calls and outgoing HTTP requests. The Context page shows the cancellation chain in detail.

ServeMux patterns

http.NewServeMux() returns the standard router. Until Go 1.21 it matched only a path prefix, which is why older books and tutorials recommend a third-party router for anything with a method or a variable in the path. Go 1.22 replaced the matching rules (see the blog post Routing Enhancements for Go 1.22); the old behaviour can still be restored with GODEBUG=httpmuxgo121=1, but new code never needs it. The pattern grammar is:

[METHOD ][HOST]/[PATH]
Pattern Matches Notes

/books

exactly /books

Any method. A pattern with no trailing slash and no wildcard matches one path.

GET /books

GET and HEAD for /books

A GET pattern also serves HEAD. Other methods get 405 Method Not Allowed with an Allow header.

POST /books

POST for /books

Method names are case-sensitive; one or more spaces or tabs may follow the method (Go 1.23).

/books/

everything under /books/

A trailing slash makes a subtree pattern. A request for /books is redirected to /books/.

/{$}

exactly /

{$} anchors the match at the end; without it / would match every path.

GET /books/{isbn}

/books/9780441172719

A {name} wildcard matches exactly one path segment; read it with r.PathValue("isbn").

/files/{path...}

/files/a/b/c.txt

A final {name...} wildcard matches the rest of the path (zero or more segments).

books.shelf.test/books

/books on that Host only

A host before the first slash restricts the pattern to that host. When a host pattern and a host-less pattern would conflict, the host pattern wins (a backwards-compatibility exception).

A wildcard must be a whole segment and it must be named with a valid Go identifier: /books/b{x}, /books/{x}y and /books/{} are rejected, and {name...} and {$} may appear only at the end. A trailing slash in a pattern is shorthand for an anonymous {...} wildcard. The route table below is a complete mux; the program sends each request straight to the mux with httptest.NewRecorder, so no listener is needed:

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
	"strings"
)

func main() {
	mux := http.NewServeMux()
	handle := func(pattern string) {
		mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
			fmt.Fprintf(w, "matched %q", r.Pattern)
			for _, name := range []string{"isbn", "rest"} {
				if v := r.PathValue(name); v != "" {
					fmt.Fprintf(w, " %s=%q", name, v)
				}
			}
		})
	}
	handle("/{$}")
	handle("GET /books")
	handle("POST /books")
	handle("GET /books/{isbn}")
	handle("GET /books/{isbn}/loans")
	handle("GET /books/new")
	handle("/files/{rest...}")
	handle("GET /api/")
	handle("books.shelf.test/books")

	try := func(method, target string) {
		req := httptest.NewRequest(method, target, nil)
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req) // the mux is an http.Handler: no network needed
		line, _, _ := strings.Cut(rec.Body.String(), "\n")
		if allow := rec.Header().Get("Allow"); allow != "" {
			line += " (Allow: " + allow + ")"
		}
		fmt.Printf("%-6s %-34s %d %s\n", method, target, rec.Code, line)
	}
	try("GET", "/")
	try("GET", "/nothing")
	try("GET", "/books")
	try("POST", "/books")
	try("DELETE", "/books")
	try("GET", "/books/9780441172719")
	try("GET", "/books/new")
	try("GET", "/books/9780441172719/loans")
	try("GET", "/books/9780441172719/loans/extra")
	try("GET", "/files/a/b/c.txt")
	try("GET", "/files/")
	try("GET", "/api/anything/at/all")
	try("HEAD", "/books")
	try("GET", "http://books.shelf.test/books")
}
GET    /                                  200 matched "/{$}"
GET    /nothing                           404 404 page not found
GET    /books                             200 matched "GET /books"
POST   /books                             200 matched "POST /books"
DELETE /books                             405 Method Not Allowed (Allow: GET, HEAD, POST)
GET    /books/9780441172719               200 matched "GET /books/{isbn}" isbn="9780441172719"
GET    /books/new                         200 matched "GET /books/new"
GET    /books/9780441172719/loans         200 matched "GET /books/{isbn}/loans" isbn="9780441172719"
GET    /books/9780441172719/loans/extra   404 404 page not found
GET    /files/a/b/c.txt                   200 matched "/files/{rest...}" rest="a/b/c.txt"
GET    /files/                            200 matched "/files/{rest...}"
GET    /api/anything/at/all               200 matched "GET /api/"
HEAD   /books                             200 matched "GET /books"
GET    http://books.shelf.test/books      200 matched "books.shelf.test/books"

Read the output line by line:

  • GET /books/new reaches the /books/new handler, not /books/{isbn}: the most specific pattern wins, regardless of registration order. Pattern P1 is more specific than P2 when P1 matches a strict subset of the requests P2 matches (/books/new is a subset of /books/{isbn}, which is a subset of /books/); if neither is a subset of the other, the patterns conflict.

  • DELETE /books is 405 and the Allow header lists what would have worked (GET, HEAD, POST); a path that no pattern matches under any method is 404. Send your own JSON for these by wrapping the mux, not by editing the mux.

  • HEAD /books was served by GET /books, and the handler can tell with r.Method.

  • /files/{rest...} matched /files/ with an empty rest, and /api/ (a subtree) matched a path of any depth.

  • /books/<isbn>/loans/extra is 404: there is no subtree pattern under /books/<isbn>/loans.

  • Matching happens on the unescaped segments, and a wildcard value is unescaped too: a request for /a/b%2Fc gives PathValue("x") equal to b/c. If that matters (it does when the value is used to build a file name), validate it.

Registering two patterns that could match the same request without one being strictly more specific panics at registration time, which turns routing mistakes into start-up failures instead of production surprises. Syntax errors panic too. Run it and see the messages:

package main

import (
	"fmt"
	"net/http"
	"regexp"
)

func try(patterns ...string) {
	defer func() {
		if r := recover(); r != nil {
			msg := fmt.Sprint(r)
			msg = regexp.MustCompile(`\(registered at [^)]*\)`).ReplaceAllString(msg, "(registered at main.go)")
			fmt.Println("panic:", msg)
		}
	}()
	mux := http.NewServeMux()
	for _, p := range patterns {
		mux.HandleFunc(p, func(http.ResponseWriter, *http.Request) {})
	}
	fmt.Println("ok:", patterns)
}

func main() {
	try("GET /books/{isbn}", "GET /books/new") // the second is more specific
	try("/books/{id}", "/books/{isbn}")        // same set of requests
	try("GET /books/{id}", "/books/new")       // overlap, neither is more specific
	try("GET /books", "GET /books")            // registered twice
	try("GET /books/{id}/{id}")                // repeated wildcard name
	try("GET /files/{rest...}/more")           // ... only as the last segment
	try("GET")                                 // no path
	try("GET /books/{$}/more")                 // {$} only at the end
}
ok: [GET /books/{isbn} GET /books/new]
panic: pattern "/books/{isbn}" (registered at main.go) conflicts with pattern "/books/{id}" (registered at main.go):
/books/{isbn} matches the same requests as /books/{id}
panic: pattern "/books/new" (registered at main.go) conflicts with pattern "GET /books/{id}" (registered at main.go):
/books/new matches more methods than GET /books/{id}, but has a more specific path pattern
panic: pattern "GET /books" (registered at main.go) conflicts with pattern "GET /books" (registered at main.go):
GET /books matches the same requests as GET /books
panic: parsing "GET /books/{id}/{id}": at offset 16: duplicate wildcard name "id"
panic: parsing "GET /files/{rest...}/more": at offset 11: {...} wildcard not at end
panic: parsing "GET": at offset 0: host/path missing /
panic: parsing "GET /books/{$}/more": at offset 11: {$} not at end

The second panic is the interesting one: GET /books/{id} and the method-less /books/new overlap (a POST /books/new matches only the second, a GET /books/new matches both) and neither dominates the other, so the mux refuses to guess. Add the method to the literal pattern (GET /books/new) to resolve it.

Trailing slashes and redirects

A subtree pattern (/api/) means a request for /api is not served directly: the mux redirects it to /api/, unless /api is registered separately. A request whose path is not clean (// or .. segments) is redirected to the cleaned path. Since Go 1.26 these redirects use status 307 (Temporary Redirect) instead of 301 (Moved Permanently), and 307, unlike 301, tells clients to repeat the same method and body. Before Go 1.26 a browser or client could cache a 301 for a path that later became a real route, and clients typically turned a redirected POST into a GET.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	ok := func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "ok") }
	mux.HandleFunc("POST /api/", ok) // a subtree: the pattern ends in a slash

	do := func(method, target string) {
		req := httptest.NewRequest(method, target, nil)
		rec := httptest.NewRecorder()
		mux.ServeHTTP(rec, req)
		fmt.Printf("%-5s %-14s -> %d %s\n", method, target, rec.Code, rec.Header().Get("Location"))
	}
	do("POST", "/api") // missing trailing slash: redirected, method preserved
	do("POST", "/api/")
	do("POST", "/api//books") // not clean: redirected to the clean path
	do("POST", "/api/a/../b")
}
POST  /api           -> 307 /api/
POST  /api/          -> 200
POST  /api//books    -> 307 /api/books
POST  /api/a/../b    -> 307 /api/b

The toolchain confirms the claim: the mux source builds these redirects with http.StatusTemporaryRedirect, and the Go 1.26 release notes describe the change in the net/http section. The mux also strips the port from the Host header before host matching, and preserves escaped elements such as %2e and %2f instead of treating them as separators.

Other mux facts

  • Request.Pattern (Go 1.23) holds the pattern that matched. It is the right label for metrics and logs because it has a bounded set of values, unlike the URL path, which contains IDs. It is set on the request the mux received, so middleware that wraps the mux and keeps the same *http.Request pointer can read it after next.ServeHTTP returns (the logging middleware below does).

  • mux.Handle(pattern, handler), mux.HandleFunc(pattern, func) register handlers; mux.Handler(r) returns the handler and pattern that would serve a request, which is handy in tests and for the tools described in Testing.

  • Sub-routers are plain composition: mount a whole mux under a prefix with mux.Handle("/admin/", http.StripPrefix("/admin", adminMux)).

  • http.Handle and http.HandleFunc register on the global http.DefaultServeMux. That global is the reason the Go community advises never to use it.

Why not http.DefaultServeMux

A server whose Handler is nil serves http.DefaultServeMux, and any package imported anywhere in the program can register routes on it in an init function. net/http/pprof does exactly that; importing it "just for the profiler" exposes the profiling endpoints on every server in the program that uses the default mux:

package main

import (
	"fmt"
	"net"
	"net/http"
	_ "net/http/pprof" // registers /debug/pprof/* on http.DefaultServeMux
)

func main() {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	// Handler is nil, so the server uses http.DefaultServeMux.
	srv := &http.Server{}
	go srv.Serve(ln)
	defer srv.Close()

	resp, err := http.Get("http://" + ln.Addr().String() + "/debug/pprof/cmdline")
	if err != nil {
		fmt.Println(err)
		return
	}
	resp.Body.Close()
	fmt.Println("a server you never configured answers:", resp.StatusCode)

	// The fix: give the server its own mux, and mount diagnostics deliberately.
	srv2 := &http.Server{Handler: http.NewServeMux()}
	ln2, _ := net.Listen("tcp", "127.0.0.1:0")
	go srv2.Serve(ln2)
	defer srv2.Close()
	resp, err = http.Get("http://" + ln2.Addr().String() + "/debug/pprof/cmdline")
	if err != nil {
		fmt.Println(err)
		return
	}
	resp.Body.Close()
	fmt.Println("a server with its own mux answers:", resp.StatusCode)
}
a server you never configured answers: 200
a server with its own mux answers: 404

Source: net/http/pprof

Always create your own ServeMux (or any Handler), and mount diagnostics deliberately and only on an internal listener. The same global-state reasoning applies to http.DefaultClient and http.DefaultTransport on the client side.

The shelf JSON API

Here is the shelf catalogue as an HTTP service: three routes, a handler struct holding the catalog.Store interface, and four small helpers for the repetitive parts. The wire format is a separate bookJSON type, so JSON tags and field names do not leak into the domain type (the pattern from encoding/json). The program starts the server with httptest.NewServer and drives it with ten requests, including the failures:

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"

	"example.com/shelf/catalog"
)

// bookJSON is the wire format; catalog.Book stays free of JSON concerns.
type bookJSON struct {
	ISBN    string   `json:"isbn"`
	Title   string   `json:"title"`
	Authors []string `json:"authors"`
}

func toJSON(b catalog.Book) bookJSON {
	out := bookJSON{ISBN: b.ISBN.String(), Title: b.Title, Authors: []string{}}
	for _, a := range b.Authors {
		out.Authors = append(out.Authors, a.Name)
	}
	return out
}

type api struct {
	store catalog.Store
}

// routes returns the whole service as one http.Handler.
func (a *api) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /books", a.listBooks)
	mux.HandleFunc("POST /books", a.createBook)
	mux.HandleFunc("GET /books/{isbn}", a.getBook)
	return mux
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

func writeError(w http.ResponseWriter, status int, msg string) {
	writeJSON(w, status, map[string]string{"error": msg})
}

// decodeBody reads exactly one JSON value of at most 4 KiB.
func decodeBody(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		return err
	}
	if _, err := dec.Token(); err != io.EOF {
		return errors.New("body must contain a single JSON value")
	}
	return nil
}

func (a *api) listBooks(w http.ResponseWriter, r *http.Request) {
	books := a.store.All()
	slices.SortFunc(books, func(x, y catalog.Book) int { return strings.Compare(x.Title, y.Title) })
	out := make([]bookJSON, 0, len(books))
	for _, b := range books {
		out = append(out, toJSON(b))
	}
	writeJSON(w, http.StatusOK, out)
}

func (a *api) getBook(w http.ResponseWriter, r *http.Request) {
	isbn, err := catalog.ParseISBN(r.PathValue("isbn"))
	if err != nil {
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	b, ok := a.store.Get(isbn)
	if !ok {
		writeError(w, http.StatusNotFound, "no such book")
		return
	}
	writeJSON(w, http.StatusOK, toJSON(b))
}

func (a *api) createBook(w http.ResponseWriter, r *http.Request) {
	var in bookJSON
	if err := decodeBody(w, r, &in); err != nil {
		var tooBig *http.MaxBytesError
		if errors.As(err, &tooBig) {
			writeError(w, http.StatusRequestEntityTooLarge, fmt.Sprintf("body larger than %d bytes", tooBig.Limit))
			return
		}
		writeError(w, http.StatusBadRequest, err.Error())
		return
	}
	isbn, err := catalog.ParseISBN(in.ISBN)
	if err != nil {
		writeError(w, http.StatusUnprocessableEntity, err.Error())
		return
	}
	book := catalog.Book{ISBN: isbn, Title: in.Title}
	for _, name := range in.Authors {
		book.Authors = append(book.Authors, catalog.Author{Name: name})
	}
	a.store.Put(book)
	w.Header().Set("Location", "/books/"+isbn.String())
	writeJSON(w, http.StatusCreated, toJSON(book))
}

func main() {
	srv := httptest.NewServer((&api{store: catalog.NewMemStore()}).routes())
	defer srv.Close()
	c := srv.Client()

	do := func(method, path, body string) {
		req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
		if body != "" {
			req.Header.Set("Content-Type", "application/json")
		}
		resp, err := c.Do(req)
		if err != nil {
			fmt.Println(err)
			return
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		loc := ""
		if l := resp.Header.Get("Location"); l != "" {
			loc = " Location=" + l
		}
		short := body
		if len(short) > 40 {
			short = short[:40] + "..."
		}
		fmt.Printf("%s %s %s\n  -> %d%s %s", method, path, short, resp.StatusCode, loc, b)
	}

	do("POST", "/books", `{"isbn":"978-0-441-17271-9","title":"Dune","authors":["Frank Herbert"]}`)
	do("GET", "/books/9780441172719", "")
	do("GET", "/books", "")
	do("GET", "/books/9780000000002", "")
	do("GET", "/books/123", "")
	do("POST", "/books", `{"isbn":"978-0-441-17271-9","titel":"typo"}`)
	do("POST", "/books", `{"isbn":"978-0-441-17271-9"} {"again":true}`)
	do("POST", "/books", `{"isbn":"1234567890123","title":"Bad checksum"}`)
	do("POST", "/books", `{"isbn":"978-0-441-17271-9","title":"`+strings.Repeat("x", 5000)+`"}`)
	do("DELETE", "/books/9780441172719", "")
}
POST /books {"isbn":"978-0-441-17271-9","title":"Dun...
  -> 201 Location=/books/9780441172719 {"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}
GET /books/9780441172719
  -> 200 {"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}
GET /books
  -> 200 [{"isbn":"9780441172719","title":"Dune","authors":["Frank Herbert"]}]
GET /books/9780000000002
  -> 404 {"error":"no such book"}
GET /books/123
  -> 400 {"error":"catalog: invalid ISBN: \"123\""}
POST /books {"isbn":"978-0-441-17271-9","titel":"typ...
  -> 400 {"error":"json: unknown field \"titel\""}
POST /books {"isbn":"978-0-441-17271-9"} {"again":tr...
  -> 400 {"error":"body must contain a single JSON value"}
POST /books {"isbn":"1234567890123","title":"Bad che...
  -> 422 {"error":"catalog: invalid ISBN: \"1234567890123\""}
POST /books {"isbn":"978-0-441-17271-9","title":"xxx...
  -> 413 {"error":"body larger than 4096 bytes"}
DELETE /books/9780441172719
  -> 405 Method Not Allowed

What the handlers do, and why:

  • routes() returns an http.Handler. The program that owns the listener, the middleware and the timeouts is separate from the program that knows the routes, which is what makes the handlers testable without a network (see Testing handlers and servers).

  • Status codes mean something. 201 Created with a Location header for a new resource, 404 for a missing one, 400 for malformed input (a bad ISBN in the path, an unknown JSON field, trailing garbage), 422 Unprocessable Entity (RFC 9110 now calls it Unprocessable Content) for well-formed input that fails validation (the checksum), 413 for an oversized body, 405 from the mux.

  • http.MaxBytesReader(w, r.Body, n) wraps the body so that reading more than n bytes fails with *http.MaxBytesError (check it with errors.As) and, if possible, makes the server close the connection after the response. Without it a client can stream gigabytes into json.Decoder and the handler dutifully allocates. Put it on every handler that reads a body, or on a whole subtree with http.MaxBytesHandler. ParseForm has its own 10 MiB default cap when no limit was set.

  • DisallowUnknownFields and the single-value check make typos visible ("titel") and reject a second JSON document, instead of silently accepting half of what the client meant.

  • json.NewEncoder(w).Encode(v) writes after WriteHeader, so the status is already sent when an encoding error could occur. For the small, fixed shapes here that cannot fail; for a large or fallible response, marshal into a buffer first and write only when it succeeded.

  • The error body is JSON too ({"error": "..."}), so clients parse failures the same way they parse successes. A fuller convention for this is RFC 9457 problem details; the standard library has no helper for it.

The routes could be mounted unchanged in a larger program, because everything about the service is behind one http.Handler. The next sections wrap it, configure the server around it and run it.

Forms and cookies

Not every client sends JSON. HTML forms send application/x-www-form-urlencoded (or multipart/form-data for file uploads), and sessions travel in cookies. r.ParseForm() merges the query string and the body into r.Form and puts only the body fields in r.PostForm; r.FormValue(key) calls ParseForm for you but ignores its error, which is why production code calls ParseForm explicitly, behind MaxBytesReader. For uploads, r.ParseMultipartForm(maxMemory) keeps at most maxMemory bytes of file parts in memory and spools the rest to temporary files, which you release with r.MultipartForm.RemoveAll(); r.FormFile(name) returns one file. HTML pages are produced with the html/template package (see Text and HTML Templates), which escapes by context, instead of string concatenation.

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/cookiejar"
	"net/http/httptest"
	"net/url"
	"strings"
)

func main() {
	mux := http.NewServeMux()

	// Forms: ParseForm reads the query string and a urlencoded body.
	mux.HandleFunc("POST /loans", func(w http.ResponseWriter, r *http.Request) {
		r.Body = http.MaxBytesReader(w, r.Body, 1<<10)
		if err := r.ParseForm(); err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		fmt.Fprintf(w, "isbn=%s member=%s via-query=%s\n",
			r.PostFormValue("isbn"), r.PostForm.Get("member"), r.URL.Query().Get("src"))
	})

	// Cookies: the server sets one, the client jar returns it.
	mux.HandleFunc("POST /login", func(w http.ResponseWriter, r *http.Request) {
		http.SetCookie(w, &http.Cookie{
			Name:     "session",
			Value:    "demo-session-id", // a real one is long, random and stored server-side
			Path:     "/",
			HttpOnly: true,                 // not readable by JavaScript
			Secure:   false,                // true in production (HTTPS only)
			SameSite: http.SameSiteLaxMode, // sent on same-site and top-level navigations
			MaxAge:   3600,
		})
		fmt.Fprintln(w, "logged in")
	})
	mux.HandleFunc("GET /me", func(w http.ResponseWriter, r *http.Request) {
		c, err := r.Cookie("session")
		if err != nil { // http.ErrNoCookie
			http.Error(w, err.Error(), http.StatusUnauthorized)
			return
		}
		fmt.Fprintf(w, "hello, session %s\n", c.Value)
	})

	srv := httptest.NewServer(mux)
	defer srv.Close()

	show := func(resp *http.Response, err error) {
		if err != nil {
			fmt.Println(err)
			return
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		fmt.Printf("%d %s", resp.StatusCode, b)
	}

	show(http.PostForm(srv.URL+"/loans?src=web", url.Values{"isbn": {"9780441172719"}, "member": {"ada"}}))
	show(http.Post(srv.URL+"/loans", "application/json", strings.NewReader(`{"isbn":"1"}`)))

	jar, _ := cookiejar.New(nil)
	client := &http.Client{Jar: jar}
	show(client.Get(srv.URL + "/me"))
	show(client.Post(srv.URL+"/login", "", nil))
	show(client.Get(srv.URL + "/me"))
	u, _ := url.Parse(srv.URL)
	for _, c := range jar.Cookies(u) {
		fmt.Println("jar holds:", c.Name)
	}
}
200 isbn=9780441172719 member=ada via-query=web
200 isbn= member= via-query=
401 http: named cookie not present
200 logged in
200 hello, session demo-session-id
jar holds: session
  • A JSON body is not a form: ParseForm ignores the second request’s body, so its fields are empty and no error is reported. Check Content-Type yourself when a handler accepts only one format.

  • http.SetCookie writes a Set-Cookie header. A session cookie wants HttpOnly (hidden from JavaScript), Secure (HTTPS only; the example is on plain HTTP so it is off there) and a SameSite mode; SameSite=Lax is a useful default, and Strict is stronger. These attributes complement, and do not replace, the protection in CrossOriginProtection. The cookie value in a real system is long, random and looked up server-side, or a signed or encrypted token; see the access and refresh tokens and JWT and JOSE pages for token formats, and browser-based apps for the cookie-versus-token trade-off.

  • r.Cookie(name) returns http.ErrNoCookie (http: named cookie not present) when the cookie is absent.

Middleware

Cross-cutting behaviour (logging, request IDs, panic recovery, authentication, compression, limits) is written as middleware: a function that takes a handler and returns a handler, func(http.Handler) http.Handler. Because a Handler is an interface and HandlerFunc is a function type, a middleware is a closure that captures next and returns a HandlerFunc; it runs code before calling next.ServeHTTP, after it, or instead of it. No framework is needed. The standard library has a handful of ready-made ones (http.TimeoutHandler, http.MaxBytesHandler, http.StripPrefix, http.CrossOriginProtection.Handler), and the pattern for your own looks like this:

package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"
	"strconv"
	"sync/atomic"
)

// Middleware wraps a handler with behaviour that runs before and/or after it.
type Middleware func(http.Handler) http.Handler

// Chain applies middleware so that the first one listed is the outermost.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

type ctxKey struct{}

// RequestID stores a request ID in the context and echoes it in the response.
func RequestID() Middleware {
	var n atomic.Int64
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			id := "req-" + strconv.FormatInt(n.Add(1), 10)
			w.Header().Set("X-Request-ID", id)
			next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, id)))
		})
	}
}

// statusRecorder remembers what the handler wrote.
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (s *statusRecorder) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(p []byte) (int, error) {
	if s.status == 0 {
		s.status = http.StatusOK
	}
	n, err := s.ResponseWriter.Write(p)
	s.bytes += n
	return n, err
}

// Unwrap lets http.ResponseController reach Flusher, deadlines and Hijacker.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

// Logging logs one structured line per request, after the handler returns.
func Logging(log *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			rec := &statusRecorder{ResponseWriter: w}
			next.ServeHTTP(rec, r)
			id, _ := r.Context().Value(ctxKey{}).(string)
			log.LogAttrs(r.Context(), slog.LevelInfo, "request",
				slog.String("id", id),
				slog.String("method", r.Method),
				slog.String("pattern", r.Pattern),
				slog.Int("status", rec.status),
				slog.Int("bytes", rec.bytes))
		})
	}
}

// Recover turns a panic in a handler into a 500 instead of a dropped connection.
func Recover(log *slog.Logger) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if v := recover(); v != nil {
					if v == http.ErrAbortHandler {
						panic(v) // deliberate abort: let net/http handle it
					}
					log.Error("panic in handler", "panic", v)
					http.Error(w, "internal server error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

func main() {
	log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey && len(groups) == 0 {
				return slog.Attr{} // drop the time to keep the output stable
			}
			return a
		},
	}))

	mux := http.NewServeMux()
	mux.HandleFunc("GET /ping", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "pong")
	})
	mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) {
		panic("catalogue is on fire")
	})

	// Logging is inside RequestID (it can read the ID) and outside Recover
	// (it sees the 500 that Recover writes).
	h := Chain(mux, RequestID(), Logging(log), Recover(log))

	srv := httptest.NewServer(h)
	defer srv.Close()
	for _, path := range []string{"/ping", "/boom", "/missing"} {
		resp, err := http.Get(srv.URL + path)
		if err != nil {
			fmt.Println(err)
			return
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("%s -> %d %s id=%s\n", path, resp.StatusCode, string(body[:len(body)-1]), resp.Header.Get("X-Request-ID"))
	}
}
level=INFO msg=request id=req-1 method=GET pattern="GET /ping" status=200 bytes=5
/ping -> 200 pong id=req-1
level=ERROR msg="panic in handler" panic="catalogue is on fire"
level=INFO msg=request id=req-2 method=GET pattern="GET /boom" status=500 bytes=22
/boom -> 500 internal server error id=req-2
level=INFO msg=request id=req-3 method=GET pattern="" status=404 bytes=19
/missing -> 404 404 page not found id=req-3

Points worth knowing:

  • Order is nesting. Chain(mux, RequestID(), Logging(log), Recover(log)) makes RequestID the outermost layer and Recover the innermost, closest to the mux. A request passes down through the layers and the response comes back up through the same layers. Logging sits outside Recover so it sees the 500 that Recover writes; if it sat inside, a panic would unwind through it and skip the log line. Authentication belongs outside the handler it protects and inside logging; compression is outermost on the response path.

  • Wrapping ResponseWriter hides optional interfaces. The server’s writer also implements http.Flusher, http.Hijacker and, on HTTP/2, more; a struct that embeds only the http.ResponseWriter interface exposes none of them, so w.(http.Flusher) silently fails behind your wrapper. The fix (Go 1.20) is the Unwrap() http.ResponseWriter method on the wrapper, which http.ResponseController follows: handlers then call http.NewResponseController(w).Flush() and it works through any number of wrappers. Libraries that wrap writers without Unwrap are the usual reason streaming "does not work behind the middleware".

  • r.WithContext(ctx) and the same pointer. A middleware that adds to the context passes a copy made with r.WithContext(newCtx) to next; the downstream handlers see the new context. The logging middleware reads r.Pattern after the call because the mux set it on the request value that travelled through the chain (a middleware that passed its own copy of the request to next would not see the pattern in its variable).

  • Context values use an unexported key type (ctxKey struct{}), so no other package can collide with or forge the value; see Context for the rules and for why values must stay request-scoped data, not dependencies.

  • Duration and size are the useful log fields, and timing is deliberately left out of the output above to keep it stable. Log method, the mux pattern (not the raw path, which has unbounded cardinality), status, duration and bytes; see Structured Logging with slog for handler setup and LogAttrs.

  • Per-route middleware is just wrapping at registration: mux.Handle("POST /books", requireAuth(http.HandlerFunc(a.createBook))). Group-level wrapping is a sub-mux wrapped once and mounted with StripPrefix.

  • Recovering from panics with recover must re-panic http.ErrAbortHandler, and must not try to write a response if the handler had already started one (the status is already sent; closing the connection is the honest outcome).

The route a request takes through a service built like this is:

sequenceDiagram participant C as Client participant S as http.Server participant R as RequestID participant L as Logging participant P as Recover participant X as ServeMux participant H as getBook handler C->>S: GET /books/9780441172719 Note over S: read headers within ReadHeaderTimeout,
start one goroutine for this request S->>R: ServeHTTP(w, r) R->>R: store request ID in the context R->>L: next.ServeHTTP(w, r with ID) L->>P: next.ServeHTTP(statusRecorder, r) P->>X: next.ServeHTTP(w, r) X->>X: match pattern GET /books/{isbn},
set r.Pattern and PathValue X->>H: ServeHTTP(w, r) H-->>X: JSON written with status 200 X-->>P: returns P-->>L: returns (a panic here would become a 500) L->>L: log method, pattern, status, bytes L-->>R: returns R-->>S: returns S-->>C: response (WriteTimeout covers handler and write)

Third-party routers and middleware collections (chi, gorilla/mux, alice and others) are packages built on exactly this signature, which is why they interoperate with the standard library. Since Go 1.22 the main reasons to adopt a router (methods and path variables) are gone from the standard library; see Alternatives and frameworks.

CrossOriginProtection

Cross-site request forgery (CSRF) tricks a user’s browser into sending a state-changing request to your site while the browser attaches the user’s cookies. Go 1.25 added http.CrossOriginProtection, a middleware that defends against it without tokens or cookies, by using the browser’s fetch metadata: every modern browser (all since 2023) sends a Sec-Fetch-Site header on requests, saying whether the request came from the same origin, the same site, another site or directly from the user (none).

The check, in the order the implementation applies it:

  1. GET, HEAD and OPTIONS are always allowed. They are the safe methods; this is why a GET handler must never change state.

  2. If Sec-Fetch-Site is same-origin or none, the request is allowed. If it is any other value (same-site and cross-site included), the request is allowed only when its Origin is a trusted origin or it matches a bypass pattern; otherwise it is rejected with 403 Forbidden.

  3. If the header is absent (an old browser, or not a browser), the Origin header is compared with Host: equal hosts are allowed, a different host is rejected unless trusted or bypassed.

  4. A request with neither header is assumed to be same-origin or a non-browser client (curl, a mobile app, another server) and is allowed: CSRF is a browser problem, and non-browser clients do not carry ambient cookies.

package main

import (
	"fmt"
	"net/http"
	"net/http/httptest"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /loans", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "loan created")
	})
	mux.HandleFunc("GET /loans", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "loan list")
	})
	mux.HandleFunc("POST /webhooks/payments", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "webhook accepted")
	})

	cop := http.NewCrossOriginProtection()
	if err := cop.AddTrustedOrigin("https://admin.shelf.example"); err != nil {
		panic(err)
	}
	// Server-to-server callbacks are not browser requests; authenticate them
	// by signature instead (see the OAuth pages for token-based options).
	cop.AddInsecureBypassPattern("POST /webhooks/payments")
	h := cop.Handler(mux)

	try := func(label, method, target string, hdr map[string]string) {
		req := httptest.NewRequest(method, target, nil)
		for k, v := range hdr {
			req.Header.Set(k, v)
		}
		rec := httptest.NewRecorder()
		h.ServeHTTP(rec, req)
		fmt.Printf("%-34s %d %s", label, rec.Code, rec.Body.String())
	}
	try("curl, no browser headers", "POST", "/loans", nil)
	try("same-origin form post", "POST", "/loans", map[string]string{"Sec-Fetch-Site": "same-origin"})
	try("cross-site form post", "POST", "/loans", map[string]string{"Sec-Fetch-Site": "cross-site"})
	try("cross-site GET (safe method)", "GET", "/loans", map[string]string{"Sec-Fetch-Site": "cross-site"})
	try("old browser, other Origin", "POST", "/loans", map[string]string{"Origin": "https://evil.example"})
	try("old browser, same Origin", "POST", "/loans", map[string]string{"Origin": "http://example.com"})
	try("trusted admin origin", "POST", "/loans", map[string]string{"Origin": "https://admin.shelf.example", "Sec-Fetch-Site": "cross-site"})
	try("bypass pattern, cross-site", "POST", "/webhooks/payments", map[string]string{"Sec-Fetch-Site": "cross-site"})

	err := cop.Check(func() *http.Request {
		r := httptest.NewRequest("POST", "/loans", nil)
		r.Header.Set("Sec-Fetch-Site", "cross-site")
		return r
	}())
	fmt.Println("Check:", err)
}
curl, no browser headers           200 loan created
same-origin form post              200 loan created
cross-site form post               403 cross-origin request detected from Sec-Fetch-Site header
cross-site GET (safe method)       200 loan list
old browser, other Origin          403 cross-origin request detected, and/or browser is out of date: Sec-Fetch-Site is missing, and Origin does not match Host
old browser, same Origin           200 loan created
trusted admin origin               200 loan created
bypass pattern, cross-site         200 webhook accepted
Check: cross-origin request detected from Sec-Fetch-Site header
  • http.NewCrossOriginProtection() returns the middleware; the zero value is also valid and has no trusted origins. Wrap the whole mux with cop.Handler(mux), or call cop.Check(r) yourself when you want a custom failure path; the default response is 403 with a short text message, and SetDenyHandler replaces it (Check never calls the deny handler).

  • AddTrustedOrigin("https://admin.shelf.example") lets a specific other origin (exact scheme, host and port) call the service, for example a separately hosted front end. AddInsecureBypassPattern("POST /webhooks/payments") exempts routes that are not meant for browsers, such as webhooks authenticated by a signature. The bypass pattern uses the ServeMux syntax and precedence rules and matches only requests that reach the pattern directly (not those the mux would redirect). The word "insecure" is the library’s own warning.

  • It is CSRF protection, not CORS. CORS (cross-origin resource sharing) is the browser’s read policy for cross-origin responses, controlled by Access-Control-* response headers; it never stops a request from being sent. CrossOriginProtection stops the dangerous cross-origin writes from being processed. A service that is called from another origin’s JavaScript needs both ideas: the trusted-origin list here for writes, and CORS headers for reads. The site’s CORS page explains the browser mechanism, and the CSRF and CORS sections of Axum CSRF and security hardening and NestJS security headers, CORS and CSRF compare the same defences in other stacks. The standard library does not implement CORS headers; write a small middleware or use a maintained package.

  • It protects cookie-authenticated sessions, where the browser adds credentials automatically. APIs that authenticate with an Authorization: Bearer header the browser does not add by itself are not CSRF-prone in the same way; the token formats and where to keep them are in OAuth for browser-based apps and the OAuth security best practices.

  • Before Go 1.25 the same protection needed a token library (gorilla/csrf and similar) with per-session secrets and hidden form fields. Those are still the answer for old browsers that must be supported and for sites that need token binding.

Configuring http.Server

http.ListenAndServe(addr, handler) creates a Server with every field zero. That server has no timeouts at all: a client that opens a connection and sends one byte a minute holds a goroutine and a file descriptor indefinitely (the "Slowloris" attack, and also what a flaky mobile network does by accident). A production server is configured explicitly:

Field Meaning

Addr

host:port for ListenAndServe; empty means :http. Bind 127.0.0.1:port for something that must not be reachable from other machines.

Handler

The root handler. nil means http.DefaultServeMux, which you should avoid (see above).

ReadHeaderTimeout

Time allowed to read the request line and headers. Set it on every server; it is the cheapest defence against slow clients. Zero falls back to ReadTimeout.

ReadTimeout

Maximum duration for reading the entire request, body included. A blunt limit: it cannot be adjusted per handler except through ResponseController.

WriteTimeout

Time from the end of reading the request headers to the end of writing the response. It includes the handler’s processing time, so it must exceed your slowest legitimate response.

IdleTimeout

How long a keep-alive connection waits for the next request. Zero falls back to ReadTimeout.

MaxHeaderBytes

Limit on the request line and headers (default http.DefaultMaxHeaderBytes, 1 MiB). Does not limit the body.

MaxHeaderValueCount

Limit on the number of header values (default http.DefaultMaxHeaderValueCount, 500; added in Go 1.27).

ErrorLog

A *log.Logger for server-internal messages (superfluous WriteHeader, handler panics, accept errors). Route it into your structured logs with slog.NewLogLogger(handler, level).

ConnState

Callback on connection state changes (StateNew, StateActive, StateIdle, StateHijacked, StateClosed): the hook for connection metrics.

BaseContext, ConnContext

Derive the root context of all requests (BaseContext) or of one connection (ConnContext); the request context descends from them.

TLSConfig, TLSNextProto

TLS settings for ServeTLS and ListenAndServeTLS, and the ALPN take-over hook.

Protocols, HTTP2, DisableClientPriority

Protocol selection and HTTP/2 tuning (Go 1.24, 1.27).

DisableGeneralOptionsHandler

Pass OPTIONS * to the handler instead of answering 200.

Pick the numbers from your traffic: a few seconds for ReadHeaderTimeout, tens of seconds for ReadTimeout and WriteTimeout unless the service moves large bodies, a minute or two for IdleTimeout. The figure shows which parts of one request each of them covers.

Timeline of one request on a keep-alive connection showing the span covered by ReadHeaderTimeout, ReadTimeout, WriteTimeout, http.TimeoutHandler and IdleTimeout, and a note that ResponseController deadlines override them per request

The behaviour is easiest to believe when it is observed. The program below sets deliberately tiny timeouts and shows a stalled client being dropped, a handler that outlives WriteTimeout, the same slow handler succeeding after it extends its own deadline with ResponseController, and an idle keep-alive connection being closed:

package main

import (
	"bufio"
	"fmt"
	"io"
	"net"
	"net/http"
	"strings"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /fast", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "fast")
	})
	mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
		time.Sleep(600 * time.Millisecond) // longer than WriteTimeout
		fmt.Fprintln(w, "slow but finished")
	})
	mux.HandleFunc("GET /export", func(w http.ResponseWriter, r *http.Request) {
		// This one endpoint is allowed more time than the server default.
		rc := http.NewResponseController(w)
		if err := rc.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		time.Sleep(600 * time.Millisecond)
		fmt.Fprintln(w, "export finished")
	})

	srv := &http.Server{
		Handler:           mux,
		ReadHeaderTimeout: 200 * time.Millisecond,
		WriteTimeout:      300 * time.Millisecond,
		IdleTimeout:       300 * time.Millisecond,
	}
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	go srv.Serve(ln)
	defer srv.Close()
	addr := ln.Addr().String()

	// 1. ReadHeaderTimeout: a client that sends half a request line and stalls.
	conn, _ := net.Dial("tcp", addr)
	fmt.Fprint(conn, "GET /fast HTTP/1.1\r\nHost: x\r\nX-Slow: ")
	conn.SetReadDeadline(time.Now().Add(3 * time.Second))
	reply, err := io.ReadAll(conn)
	conn.Close()
	fmt.Printf("stalled header: %d bytes received, connection closed by server: %t\n", len(reply), err == nil)

	// 2. WriteTimeout: the handler outlives the write deadline.
	for _, path := range []string{"/fast", "/slow", "/export"} {
		resp, err := http.Get("http://" + addr + path)
		if err != nil {
			fmt.Printf("GET %-7s error: %v\n", path, strings.TrimPrefix(err.Error(), `Get "http://`+addr+path+`": `))
			continue
		}
		body, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("GET %-7s %d %q\n", path, resp.StatusCode, body)
	}

	// 3. IdleTimeout: an idle keep-alive connection is closed by the server.
	conn, _ = net.Dial("tcp", addr)
	fmt.Fprint(conn, "GET /fast HTTP/1.1\r\nHost: x\r\n\r\n")
	br := bufio.NewReader(conn)
	resp, _ := http.ReadResponse(br, nil)
	io.Copy(io.Discard, resp.Body)
	resp.Body.Close()
	conn.SetReadDeadline(time.Now().Add(3 * time.Second))
	_, err = br.ReadByte()
	fmt.Printf("idle connection: first response %d, then %v\n", resp.StatusCode, err)
	conn.Close()
}
stalled header: 0 bytes received, connection closed by server: true
GET /fast   200 "fast\n"
GET /slow   error: EOF
GET /export 200 "export finished\n"
idle connection: first response 200, then EOF
  • A client that sends part of a header line and stops is cut off after ReadHeaderTimeout without a response.

  • /slow sleeps 600 ms but WriteTimeout is 300 ms: the server closes the connection when the handler finally writes, and the client sees EOF and no response. A write timeout does not cancel the handler, which keeps running until it returns; only http.TimeoutHandler (below) and the request context do that.

  • /export calls rc.SetWriteDeadline(...) first and finishes. ResponseController (Go 1.20) is the way to give one endpoint a longer (or shorter) deadline than the server default, with SetReadDeadline for slow uploads. It returns http.ErrNotSupported (compare with errors.Is) when the connection cannot do it, such as through a wrapper that lacks Unwrap.

  • The idle connection delivers the first response and is then closed by the server after IdleTimeout.

A timeout that cancels the work is a different tool from a connection deadline. http.TimeoutHandler(h, d, msg) runs h with a context that expires after d and writes a 503 Service Unavailable with msg if h has not finished. Its caveats: the wrapped handler runs with a buffered writer, so it cannot flush or hijack (no streaming), and a handler that ignores its context keeps running after the 503 was sent. The same program below shows it, ResponseController.Flush on a server-sent events stream and http.MaxBytesHandler:

package main

import (
	"bufio"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

func main() {
	mux := http.NewServeMux()

	// Server-Sent Events: flush after every event so the client sees it now.
	mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		rc := http.NewResponseController(w)
		for i := 1; i <= 3; i++ {
			select {
			case <-r.Context().Done(): // client went away
				return
			case <-time.After(20 * time.Millisecond):
			}
			fmt.Fprintf(w, "event: loan\ndata: book %d lent\n\n", i)
			if err := rc.Flush(); err != nil {
				return // the writer cannot flush (wrapped without Unwrap)
			}
		}
	})

	// TimeoutHandler: the whole handler gets 100 ms, then the client sees a 503.
	slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		select {
		case <-r.Context().Done(): // TimeoutHandler cancels the context
		case <-time.After(2 * time.Second):
		}
	})
	mux.Handle("GET /report", http.TimeoutHandler(slow, 100*time.Millisecond, "report took too long\n"))

	// MaxBytesHandler: a limit for the whole subtree, applied before any handler.
	upload := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n, err := io.Copy(io.Discard, r.Body)
		if err != nil {
			http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
			return
		}
		fmt.Fprintf(w, "read %d bytes\n", n)
	})
	mux.Handle("POST /import", http.MaxBytesHandler(upload, 16))

	srv := httptest.NewServer(mux)
	defer srv.Close()

	resp, _ := http.Get(srv.URL + "/events")
	sc := bufio.NewScanner(resp.Body)
	for sc.Scan() {
		if sc.Text() != "" {
			fmt.Println("sse:", sc.Text())
		}
	}
	resp.Body.Close()

	resp, _ = http.Get(srv.URL + "/report")
	b, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	fmt.Printf("report: %d %q\n", resp.StatusCode, b)

	for _, size := range []int{10, 100} {
		resp, _ = http.Post(srv.URL+"/import", "text/plain", strings.NewReader(strings.Repeat("x", size)))
		b, _ = io.ReadAll(resp.Body)
		resp.Body.Close()
		fmt.Printf("import %3d bytes: %d %s", size, resp.StatusCode, b)
	}
}
sse: event: loan
sse: data: book 1 lent
sse: event: loan
sse: data: book 2 lent
sse: event: loan
sse: data: book 3 lent
report: 503 "report took too long\n"
import  10 bytes: 200 read 10 bytes
import 100 bytes: 413 http: request body too large

Server-sent events need only three things: the text/event-stream content type, event: and data: lines ending in a blank line, and a flush after each event. A handler that streams must watch r.Context().Done(), because that is the only signal that the client left. ResponseController also offers Hijack (take over the raw connection, for protocols such as WebSocket, after which the server no longer manages it) and EnableFullDuplex (Go 1.21: read the HTTP/1 request body while writing the response, which the server otherwise forbids by discarding the unread body once you start writing).

Graceful shutdown

When a deployment replaces a process, or when Kubernetes sends SIGTERM, a server that just exits drops every request in flight. Server.Shutdown(ctx) is the alternative: it closes the listeners, so no new connections arrive, closes idle connections and waits for active ones to become idle, returning when all are done or when ctx expires. The program below arranges the pieces:

  • signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) returns a context that is cancelled on Ctrl+C or SIGTERM, replacing the older "buffered channel of os.Signal`" boilerplate. The `stop function restores default signal behaviour, so a second Ctrl+C terminates immediately.

  • Serve runs in a goroutine. It returns http.ErrServerClosed immediately when Shutdown is called, which is the expected case and not an error; the main goroutine must wait for Shutdown itself to return, not for Serve.

  • The shutdown context has its own timeout, derived from context.Background() and not from the cancelled signal context, which is already done. If the deadline passes, Shutdown returns the context’s error; calling Close then drops the remaining connections.

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

// run serves h on ln until ctx is cancelled, then drains in-flight requests.
func run(ctx context.Context, ln net.Listener, h http.Handler) error {
	srv := &http.Server{
		Handler:           h,
		ReadHeaderTimeout: 5 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	errc := make(chan error, 1)
	go func() {
		// Serve returns ErrServerClosed as soon as Shutdown is called.
		if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
			errc <- err
			return
		}
		errc <- nil
	}()

	select {
	case err := <-errc:
		return err // the listener failed
	case <-ctx.Done():
	}

	slog.Info("shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		srv.Close() // deadline passed: drop whatever is left
		return fmt.Errorf("graceful shutdown failed: %w", err)
	}
	return <-errc
}

func main() {
	slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey && len(groups) == 0 {
				return slog.Attr{}
			}
			return a
		},
	})))

	// SIGINT and SIGTERM cancel ctx; stop() restores default signal handling.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	started := make(chan struct{})
	mux := http.NewServeMux()
	mux.HandleFunc("GET /slow", func(w http.ResponseWriter, r *http.Request) {
		close(started)
		time.Sleep(300 * time.Millisecond)
		fmt.Fprintln(w, "finished the in-flight request")
	})

	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		slog.Error("listen", "err", err)
		os.Exit(1)
	}

	done := make(chan error, 1)
	go func() { done <- run(ctx, ln, mux) }()

	// Demo client: start a request, then "press Ctrl+C" while it is in flight.
	got := make(chan string, 1)
	go func() {
		resp, err := http.Get("http://" + ln.Addr().String() + "/slow")
		if err != nil {
			got <- "client error: " + err.Error()
			return
		}
		defer resp.Body.Close()
		b, _ := io.ReadAll(resp.Body)
		got <- fmt.Sprintf("client got: %d %s", resp.StatusCode, b)
	}()
	<-started
	_ = syscall.Kill(os.Getpid(), syscall.SIGINT)

	fmt.Println("run returned:", <-done)
	fmt.Print(<-got)
	_, err = http.Get("http://" + ln.Addr().String() + "/slow")
	fmt.Println("after shutdown, new connections fail:", err != nil)
}
level=INFO msg="shutting down"
run returned: <nil>
client got: 200 finished the in-flight request
after shutdown, new connections fail: true

The demo sends itself SIGINT (with syscall.Kill, which exists on Unix; on Windows use os.Interrupt handling and a different trigger) while a 300 ms request is in flight. The client still gets its 200: shutdown waited. New connections after shutdown fail.

Details that bite in production:

  • Shutdown does not wait for hijacked connections (WebSockets) and does not cancel request contexts. Long-lived handlers (streams, long polls) must be told to stop: derive their context from a server-wide context that you cancel before calling Shutdown (set BaseContext), or register a cleanup with srv.RegisterOnShutdown(func()), which runs in its own goroutine when shutdown starts.

  • Setting BaseContext to the signal context cancels every in-flight request the moment the signal arrives, which is the opposite of graceful. Give it a context that you cancel after the drain deadline, or one that you cancel to end streams only.

  • A service behind a load balancer first fails its readiness probe (so no new requests arrive), waits a moment, then calls Shutdown; the orchestrator’s grace period (terminationGracePeriodSeconds) must be longer than your shutdown timeout. The sequence and the endpoint-removal race are explained on the health probes and lifecycle page, and the Rust version of the same structure is on the Axum graceful shutdown page.

  • Return a non-zero exit code when the server failed to start or to drain, and flush logs and telemetry after Shutdown.

The shelfd program

cmd/shelfd puts the pieces together: the api from earlier (it lives in api.go in the same package, unchanged from the shelf API listing), the CSRF protection around the router, explicit timeouts, configuration from the environment and graceful shutdown. The run function returns an error so that main has one place to log it and choose the exit code.

cmd/shelfd/main.go
package main

import (
	"context"
	"errors"
	"log/slog"
	"net"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"example.com/shelf/catalog"
)

func main() {
	if err := run(); err != nil {
		slog.Error("shelfd failed", "err", err)
		os.Exit(1)
	}
}

func run() error {
	// Configuration comes from the environment, never from source code.
	addr := os.Getenv("SHELF_ADDR")
	if addr == "" {
		addr = "127.0.0.1:8080"
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	store := catalog.NewMemStore()
	isbn, _ := catalog.ParseISBN("978-0-441-17271-9")
	store.Put(catalog.Book{ISBN: isbn, Title: "Dune", Authors: []catalog.Author{{Name: "Frank Herbert"}}})

	// Reject cross-site browser writes before they reach any handler.
	handler := http.NewCrossOriginProtection().Handler((&api{store: store}).routes())

	srv := &http.Server{
		Addr:              addr,
		Handler:           handler,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       2 * time.Minute,
		MaxHeaderBytes:    64 << 10,
	}

	ln, err := net.Listen("tcp", addr)
	if err != nil {
		return err
	}
	slog.Info("listening", "addr", ln.Addr().String())

	serveErr := make(chan error, 1)
	go func() { serveErr <- srv.Serve(ln) }()

	select {
	case err := <-serveErr:
		return err // Serve only returns on failure here
	case <-ctx.Done():
		stop() // a second Ctrl+C now kills the process
	}

	slog.Info("shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		_ = srv.Close()
		return err
	}
	if err := <-serveErr; !errors.Is(err, http.ErrServerClosed) {
		return err
	}
	slog.Info("stopped")
	return nil
}

Running the compiled binary, calling it with curl and stopping it with SIGTERM (the address comes from the environment variable, and is a loopback port chosen for the demonstration):

$ SHELF_ADDR=127.0.0.1:18080 ./shelfd &
$ curl -s -i -X POST -H 'Sec-Fetch-Site: cross-site' -d '{}' http://127.0.0.1:18080/books
HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 57

cross-origin request detected from Sec-Fetch-Site header
$ kill -TERM %1

and the log it wrote:

2026/10/18 02:29:48 INFO listening addr=127.0.0.1:18081
2026/10/18 02:29:49 INFO shutting down
2026/10/18 02:29:49 INFO stopped

Serving static files

http.FileServerFS(fsys) returns a handler serving an fs.FS, and http.ServeFileFS(w, r, fsys, name) serves one file; both set Content-Type from the extension, honour If-Modified-Since and Range requests, and redirect index.html. With //go:embed the files ship inside the binary, which makes the deployment one file (see Directives, Code Generation and Embedding and I/O, Files and the Filesystem). FileServerFS, ServeFileFS and NewFileTransportFS arrived in Go 1.22; before that http.FileServer(http.FS(fsys)) and http.Dir were the only options, and http.Dir serves the operating-system file system, which is why it deserves care.

The embedded tree for this program is assets/index.html, assets/shelf.css, assets/covers/dune.txt and a dot file assets/.notes.txt:

package main

import (
	"embed"
	"fmt"
	"io"
	"io/fs"
	"net/http"
	"net/http/httptest"
	"path"
	"strings"
)

//go:embed all:assets
var assets embed.FS

func main() {
	// Serve the files at /assets/ without the "assets" directory prefix of
	// the embedded tree being doubled.
	mux := http.NewServeMux()
	mux.Handle("GET /assets/", http.FileServerFS(assets))

	// The same tree, but only some files and no directory listings.
	sub, err := fs.Sub(assets, "assets")
	if err != nil {
		panic(err)
	}
	mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(noDirs{sub})))

	// One file with ServeFileFS (never put a request path into the name).
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		http.ServeFileFS(w, r, assets, "assets/index.html")
	})

	srv := httptest.NewServer(mux)
	defer srv.Close()
	for _, p := range []string{"/", "/assets/shelf.css", "/assets/covers/", "/static/covers/", "/static/covers/dune.txt", "/assets/.notes.txt", "/static/.notes.txt", "/static/"} {
		resp, err := http.Get(srv.URL + p)
		if err != nil {
			fmt.Println(err)
			continue
		}
		b, _ := io.ReadAll(resp.Body)
		resp.Body.Close()
		first, _, _ := strings.Cut(strings.TrimSpace(string(b)), "\n")
		if strings.Contains(string(b), "<pre>") {
			first = "(directory listing)"
		}
		fmt.Printf("%-20s %d %-24s %s\n", p, resp.StatusCode, resp.Header.Get("Content-Type"), first)
	}
}

// noDirs hides directory listings and dot files behind a 404.
type noDirs struct{ fs fs.FS }

func (n noDirs) Open(name string) (fs.File, error) {
	if base := name[strings.LastIndex(name, "/")+1:]; base != "." && strings.HasPrefix(base, ".") {
		return nil, fs.ErrNotExist
	}
	f, err := n.fs.Open(name)
	if err != nil {
		return nil, err
	}
	if st, err := f.Stat(); err == nil && st.IsDir() {
		// Allow a directory only if it holds an index.html.
		idx, err := n.fs.Open(path.Join(name, "index.html"))
		if err != nil {
			f.Close()
			return nil, fs.ErrNotExist
		}
		idx.Close()
	}
	return f, nil
}
/                    200 text/html; charset=utf-8 <!doctype html>
/assets/shelf.css    200 text/css; charset=utf-8  body { font-family: sans-serif; }
/assets/covers/      200 text/html; charset=utf-8 (directory listing)
/static/covers/      404 text/plain; charset=utf-8 404 page not found
/static/covers/dune.txt 200 text/plain; charset=utf-8 cover art placeholder
/assets/.notes.txt   200 text/plain; charset=utf-8 internal notes
/static/.notes.txt   404 text/plain; charset=utf-8 404 page not found
/static/             200 text/html; charset=utf-8 <!doctype html>
  • //go:embed assets would silently skip files whose names begin with . or _; all:assets includes them, which is what makes the dot-file demonstration meaningful (the “all:” prefix is covered on the embedding page).

  • FileServerFS(assets) mounted at /assets/ serves assets/shelf.css at /assets/shelf.css because the embedded tree contains the directory name, but it also serves directory listings (/assets/covers/ returned a listing) and dot files (/assets/.notes.txt returned 200). That is the default and it is rarely what you want.

  • fs.Sub(assets, "assets") re-roots the tree, and http.StripPrefix("/static/", ...) removes the URL prefix so the server sees shelf.css. Wrapping the fs.FS in a type whose Open refuses directories without an index.html and names starting with a dot returns 404 for exactly those cases.

  • Never pass a request-controlled path to http.ServeFile or http.ServeFileFS. They refuse a request whose URL.Path contains a .. element as a precaution, but the name is still yours to validate: pass a constant, or a name you checked against a list. os.Root (Go 1.24) confines file operations to a directory and is the tool for files that come from a tree users can influence; it is covered with the other hardening measures on Crypto and Security.

  • http.ServeContent(w, r, name, modtime, io.ReadSeeker) serves content that is not in a file system, with conditional and range support. Since Go 1.23 the Serve* functions remove Cache-Control, Content-Encoding, Etag and Last-Modified headers when they serve an error (set GODEBUG=httpservecontentkeepheaders=1 to restore the old behaviour), which breaks middleware that compresses on the fly with Content-Encoding.

Reverse proxies

httputil.ReverseProxy forwards requests to another server and copies the response back, and is a handler like any other. The configuration hook is Rewrite, which receives the inbound request and the outbound request being built (SetURL routes to a target, SetXForwarded adds the forwarding headers, and header edits go on r.Out). It was added in Go 1.20 because the original Director hook cannot be made safe: a malicious client can name a header added by Director in its Connection header and have the proxy strip it. Director is deprecated since Go 1.26.

package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
)

func main() {
	backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "backend saw %s %s, X-Forwarded-For set: %t, X-Secret present: %t\n",
			r.Method, r.URL.Path, r.Header.Get("X-Forwarded-For") != "", r.Header.Get("X-Secret") != "")
	}))
	defer backend.Close()
	target, _ := url.Parse(backend.URL)

	// Rewrite replaces the deprecated Director hook (Go 1.20 added it,
	// Go 1.26 deprecated Director).
	proxy := &httputil.ReverseProxy{
		Rewrite: func(r *httputil.ProxyRequest) {
			r.SetURL(target)             // route to the backend, joining the paths
			r.SetXForwarded()            // add X-Forwarded-For, -Host and -Proto
			r.Out.Header.Del("X-Secret") // never forward internal headers
		},
	}
	front := httptest.NewServer(proxy)
	defer front.Close()

	req, _ := http.NewRequest(http.MethodGet, front.URL+"/books", nil)
	req.Header.Set("X-Secret", "internal-only")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println(err)
		return
	}
	defer resp.Body.Close()
	b, _ := io.ReadAll(resp.Body)
	fmt.Print(string(b))
}
backend saw GET /books, X-Forwarded-For set: true, X-Secret present: false

Testing handlers and servers

Because a handler is an interface, most HTTP tests need no network. net/http/httptest supplies the pieces:

Tool Use

httptest.NewRequest(method, target, body)

Builds an incoming request for a handler. It panics on a malformed target (it is meant for tests), sets RemoteAddr to 192.0.2.1:1234 and the Host to example.com, which matters for host-specific mux patterns. NewRequestWithContext (Go 1.23) takes a context.

httptest.NewRecorder()

A ResponseWriter that records status, headers and body; read rec.Code, rec.Header() and rec.Body. It does not model connections, timeouts, protocol framing or Flusher and Hijacker behaviour, so use a real server for those.

httptest.NewServer(h)

A real server on a loopback port, for testing clients and the full stack. Close it (defer srv.Close()).

httptest.NewTLSServer(h)

The same over TLS with a self-signed certificate; srv.Client() trusts it.

httptest.NewUnstartedServer(h)

Returns a server you can configure (srv.Config.ErrorLog, timeouts, EnableHTTP2) before calling Start.

httptest.NewTestServer(t, h)

Go 1.27: a server on an in-memory network that registers its own cleanup, fails the test if the handler panics, and works inside a testing/synctest bubble. srv.Client() sends every request to it, whatever the URL says.

The first test below calls the router directly through the recorder (fast, no ports), the second goes through a whole server with the Go 1.27 in-memory network, and the third combines NewTestServer with testing/synctest so that a 30-second client timeout is tested in no real time, because the bubble’s clock is fake and advances when every goroutine is blocked (see Benchmarks, Fuzzing and synctest).

cmd/shelfd/api_test.go
package main

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"testing/synctest"
	"time"

	"example.com/shelf/catalog"
)

func newAPI(t *testing.T) http.Handler {
	t.Helper()
	return (&api{store: catalog.NewMemStore()}).routes()
}

// Handler tests: no network, the recorder implements ResponseWriter.
func TestAPI(t *testing.T) {
	h := newAPI(t)
	tests := []struct {
		name   string
		method string
		target string
		body   string
		want   int
	}{
		{"create", "POST", "/books", `{"isbn":"9780441172719","title":"Dune"}`, http.StatusCreated},
		{"get", "GET", "/books/9780441172719", "", http.StatusOK},
		{"missing", "GET", "/books/9780000000002", "", http.StatusNotFound},
		{"bad isbn", "GET", "/books/123", "", http.StatusBadRequest},
		{"unknown field", "POST", "/books", `{"isbn":"9780441172719","x":1}`, http.StatusBadRequest},
		{"wrong method", "PUT", "/books", "", http.StatusMethodNotAllowed},
	}
	// The cases share state (create before get), so they run in order.
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			req := httptest.NewRequest(tt.method, tt.target, strings.NewReader(tt.body))
			rec := httptest.NewRecorder()
			h.ServeHTTP(rec, req)
			if rec.Code != tt.want {
				t.Errorf("%s %s = %d, want %d (body %q)", tt.method, tt.target, rec.Code, tt.want, rec.Body)
			}
		})
	}
}

// End to end over the in-memory network of httptest.NewTestServer.
func TestServer(t *testing.T) {
	srv := httptest.NewTestServer(t, newAPI(t))
	resp, err := srv.Client().Get("http://shelf.test/books")
	if err != nil {
		t.Fatal(err)
	}
	defer resp.Body.Close()
	if got := resp.Header.Get("Content-Type"); got != "application/json" {
		t.Errorf("Content-Type = %q", got)
	}
}

// With synctest the clock is fake: a 30 s wait costs no real time.
func TestClientTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			select {
			case <-time.After(time.Minute):
			case <-r.Context().Done():
			}
		})
		srv := httptest.NewTestServer(t, slow)
		client := srv.Client()
		client.Timeout = 30 * time.Second

		_, err := client.Get("http://shelf.test/slow")
		if !errors.Is(err, context.DeadlineExceeded) {
			t.Fatalf("err = %v, want a deadline error", err)
		}
	})
}
=== RUN   TestAPI
=== RUN   TestAPI/create
=== RUN   TestAPI/get
=== RUN   TestAPI/missing
=== RUN   TestAPI/bad_isbn
=== RUN   TestAPI/unknown_field
=== RUN   TestAPI/wrong_method
--- PASS: TestAPI (0.00s)
    --- PASS: TestAPI/create (0.00s)
    --- PASS: TestAPI/get (0.00s)
    --- PASS: TestAPI/missing (0.00s)
    --- PASS: TestAPI/bad_isbn (0.00s)
    --- PASS: TestAPI/unknown_field (0.00s)
    --- PASS: TestAPI/wrong_method (0.00s)
=== RUN   TestServer
--- PASS: TestServer (0.00s)
=== RUN   TestClientTimeout
--- PASS: TestClientTimeout (0.00s)
PASS
ok  	example.com/shelf/cmd/shelfd	0.275s
  • Test the http.Handler (routes()), not main. This is the payoff of the structure shown earlier.

  • The test table runs its cases in order because they share the store; subtests that must be independent build their own handler.

  • For a client under test, point it at an httptest.Server: the client code is exercised for real (headers, status, redirects, timeouts) and the fake server’s handler decides what happens. A server that returns 503 twice and then 200 is a few lines of test for the retry helper above (the retry example above was run against exactly such a server).

  • NewServer listens on 127.0.0.1 and leaves the port to the operating system, so tests do not collide; the in-memory NewTestServer avoids ports altogether and, since it needs no sleeps, makes timeout behaviour deterministic. Before Go 1.27 the equivalent was NewServer plus real sleeps, or a custom RoundTripper.

  • Race detection (go test -race) matters for handlers: they run concurrently by design, and the shared MemStore is exactly where an unguarded map would be found. See Testing.

  • Go 1.26 changed the client returned by the loopback Server.Client(): it also redirects example.com and its subdomains to the test server.

Raw TCP, addresses and URLs

Below HTTP is the net package, and one level of it is enough for most programs that are not HTTP services: a TCP server or client, deadlines, and the address and URL types. (Note that a hand-written HTTP/1.1 parser is never the answer: use net/http.) net.Listen("tcp", addr) returns a net.Listener; Accept returns a net.Conn, which is an io.ReadWriteCloser with deadlines. The idiom is one goroutine per connection, since goroutines are cheap, and a deadline renewed before each read, since deadlines are absolute times and not timeouts:

package main

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"net"
	"os"
	"strings"
	"syscall"
	"time"
)

// serve accepts connections until ln is closed; one goroutine per connection.
func serve(ln net.Listener) {
	for {
		conn, err := ln.Accept()
		if err != nil {
			if errors.Is(err, net.ErrClosed) {
				return
			}
			fmt.Println("accept:", err)
			return
		}
		go handle(conn)
	}
}

// handle answers "ISBN <n>" lines with an upper-cased echo; idle clients are dropped.
func handle(conn net.Conn) {
	defer conn.Close()
	sc := bufio.NewScanner(conn)
	for {
		// A deadline is absolute and must be renewed for every read.
		conn.SetReadDeadline(time.Now().Add(200 * time.Millisecond))
		if !sc.Scan() {
			return // EOF, error or deadline
		}
		fmt.Fprintf(conn, "ECHO %s\n", strings.ToUpper(sc.Text()))
	}
}

func main() {
	ln, err := net.Listen("tcp", "127.0.0.1:0") // port 0: the OS picks a free port
	if err != nil {
		panic(err)
	}
	defer ln.Close()
	go serve(ln)

	// The dialer carries the connect timeout and honours the context.
	d := net.Dialer{Timeout: time.Second}
	conn, err := d.DialContext(context.Background(), "tcp", ln.Addr().String())
	if err != nil {
		panic(err)
	}
	defer conn.Close()
	fmt.Println("local and remote are loopback:", strings.HasPrefix(conn.LocalAddr().String(), "127.0.0.1:"), strings.HasPrefix(conn.RemoteAddr().String(), "127.0.0.1:"))

	rd := bufio.NewReader(conn)
	for _, line := range []string{"dune", "neuromancer"} {
		fmt.Fprintln(conn, line)
		reply, _ := rd.ReadString('\n')
		fmt.Print(reply)
	}

	// Stay silent: the server's read deadline closes the connection.
	conn.SetReadDeadline(time.Now().Add(2 * time.Second))
	_, err = rd.ReadString('\n')
	fmt.Println("after idling:", err)

	// A deadline that expires on our side is reported as a timeout.
	conn2, _ := net.Dial("tcp", ln.Addr().String())
	defer conn2.Close()
	conn2.SetReadDeadline(time.Now().Add(20 * time.Millisecond))
	_, err = bufio.NewReader(conn2).ReadByte()
	var ne net.Error
	fmt.Println("local deadline:", errors.Is(err, os.ErrDeadlineExceeded), errors.As(err, &ne) && ne.Timeout())

	// Connecting where nobody listens fails fast.
	ln.Close()
	_, err = d.Dial("tcp", ln.Addr().String())
	var op *net.OpError
	fmt.Println("refused:", errors.As(err, &op), op.Op, errors.Is(err, syscall.ECONNREFUSED))
}
local and remote are loopback: true true
ECHO DUNE
ECHO NEUROMANCER
after idling: EOF
local deadline: true true
refused: true dial true

Source: net

  • net.Listen("tcp", "127.0.0.1:0") asks the OS for a free port; read it back with ln.Addr(). Binding :8080 listens on every interface; bind a loopback address for anything internal.

  • net.Dialer{Timeout: ...}.DialContext(ctx, ...) replaces the bare net.Dial, which has no timeout. The dialer also resolves names, tries every address a name resolves to and implements "happy eyeballs" (IPv4 and IPv6 in parallel).

  • Conn.SetReadDeadline, SetWriteDeadline and SetDeadline take an absolute time.Time; set a fresh one for each operation, or one deadline for a whole exchange. An expired deadline produces an error that satisfies errors.Is(err, os.ErrDeadlineExceeded) and net.Error.Timeout(), and leaves the connection usable if you extend the deadline. A zero time.Time clears it.

  • The accept loop ends when the listener is closed; errors.Is(err, net.ErrClosed) distinguishes that from a real failure. Handle Accept errors that are temporary (too many open files) by logging and backing off, not by exiting.

  • A refused connection is a *net.OpError wrapping syscall.ECONNREFUSED; errors.As and errors.Is reach both.

  • TCP is a byte stream, not a message stream: one Write does not equal one Read. Frame your protocol (lines, length prefixes) and use bufio to read it, as the example does with bufio.Scanner and bufio.Reader.

  • net.ListenUDP, net.DialUDP and net.ListenPacket give packet sockets, net.Pipe an in-memory synchronous Conn for tests, and net.LookupHost, LookupIP and Resolver do DNS. The Unix domain sockets are "unix" networks. For anything above raw TCP, crypto/tls.Listen and tls.Dial wrap a connection in TLS; see Crypto and Security.

The two helper packages that every network program touches are net/url and net/netip:

package main

import (
	"fmt"
	"net"
	"net/netip"
	"net/url"
)

func main() {
	// net/url: parse, inspect, build.
	u, err := url.Parse("https://reader:example-only@shelf.example:8443/books/search?q=dune&q=herbert&limit=10#top")
	if err != nil {
		panic(err)
	}
	fmt.Println("scheme:", u.Scheme, "host:", u.Host, "hostname:", u.Hostname(), "port:", u.Port())
	fmt.Println("path:", u.Path, "fragment:", u.Fragment)
	fmt.Println("user:", u.User.Username(), "redacted:", u.Redacted())
	q := u.Query()
	fmt.Println("q:", q["q"], "first:", q.Get("q"), "limit:", q.Get("limit"))

	// Build a URL safely: never concatenate user input.
	v := url.Values{}
	v.Set("q", "Dune & Messiah?")
	v.Add("tag", "sci-fi")
	next := url.URL{Scheme: "https", Host: "shelf.example", Path: "/books/search", RawQuery: v.Encode()}
	fmt.Println(next.String())
	fmt.Println(url.PathEscape("a b/c"), url.QueryEscape("a b/c"))
	fmt.Println(u.JoinPath("..", "loans").Path)
	rel, _ := url.Parse("../authors?id=7")
	fmt.Println(u.ResolveReference(rel))

	// A scheme-less string is not a URL with a host.
	bad, _ := url.Parse("shelf.example:8443/books")
	fmt.Printf("scheme=%q opaque=%q\n", bad.Scheme, bad.Opaque)
	_, err = url.Parse("http://[::1")
	fmt.Println("error:", err)

	// net/netip: small, comparable, allocation-free addresses.
	a := netip.MustParseAddr("192.0.2.17")
	p := netip.MustParsePrefix("192.0.2.0/24")
	fmt.Println(a, a.Is4(), p.Contains(a), p.Masked(), a.IsPrivate(), netip.MustParseAddr("10.1.2.3").IsPrivate())
	ap, _ := netip.ParseAddrPort("[2001:db8::1]:8080")
	fmt.Println(ap.Addr(), ap.Port(), ap.Addr().Is6())
	_, err = netip.ParseAddr("999.0.0.1")
	fmt.Println("error:", err)
	fmt.Println(a == netip.AddrFrom4([4]byte{192, 0, 2, 17}))

	host, port, _ := net.SplitHostPort("[2001:db8::1]:8080")
	fmt.Println(host, port, net.JoinHostPort("2001:db8::1", "8080"))
}
scheme: https host: shelf.example:8443 hostname: shelf.example port: 8443
path: /books/search fragment: top
user: reader redacted: https://reader:xxxxx@shelf.example:8443/books/search?q=dune&q=herbert&limit=10#top
q: [dune herbert] first: dune limit: 10
https://shelf.example/books/search?q=Dune+%26+Messiah%3F&tag=sci-fi
a%20b%2Fc a+b%2Fc
/books/loans
https://reader:example-only@shelf.example:8443/authors?id=7
scheme="shelf.example" opaque="8443/books"
error: parse "http://[::1": missing ']' in host
192.0.2.17 true true 192.0.2.0/24 false true
2001:db8::1 8080 true
error: ParseAddr("999.0.0.1"): IPv4 field has value >255
true
2001:db8::1 8080 [2001:db8::1]:8080

Source: net/url and net/netip

  • url.Parse accepts almost anything: shelf.example:8443/books parses without error as a URL whose scheme is shelf.example and whose opaque part is 8443/books, which is not what you meant. Check u.Scheme and u.Host after parsing a URL from input; url.ParseRequestURI is stricter and is meant for server-side request lines. Go 1.26 made Parse reject malformed hosts with extra colons such as http://localhost:80:80/ (the urlstrictcolons GODEBUG setting restores the old behaviour); the upcoming Go 1.28 adds url.MustParse for constants.

  • Build URLs with url.URL and url.Values, never by concatenating strings: Values.Encode() escapes and sorts keys, PathEscape and QueryEscape differ in how they treat / and spaces, JoinPath and ResolveReference do the dot-segment arithmetic of RFC 3986, and u.Redacted() replaces a password with xxxxx so URLs are safe to log.

  • netip.Addr, AddrPort and Prefix are small value types: comparable with ==, usable as map keys, allocation free and immutable, unlike net.IP (a slice) and net.IPNet. Prefer them in new code. Prefix.Contains, Masked, Addr.IsPrivate, Is4 and Is6 cover the usual access-control checks, and the parse functions return errors with precise messages. net.SplitHostPort and net.JoinHostPort handle the brackets IPv6 needs.

Alternatives and frameworks

With method and wildcard patterns, a router, middleware by composition, JSON, structured logging and a test server in the standard library, a service can be written without any framework, and most Go services in production do exactly that. Third-party routers and frameworks add things the standard library deliberately leaves out: route groups with per-group middleware, binding and validation of request structs, regular-expression or typed routes, automatic OpenAPI generation, and a larger catalogue of ready-made middleware. The official tutorial Developing a RESTful API with Go and Gin shows what the framework style looks like, and it is a reasonable first read after this page for readers who want to compare. A systematic comparison of Go web frameworks (chi, Gin, Echo, Fiber and others) is outside this reference and is tracked in issue #263; this page stays with the standard library. For gRPC, GraphQL, WebSocket and OpenAPI the same rule applies: they are separate modules, and they all plug into net/http servers or listeners.

Where older Go books are outdated

The two books used as background for this section predate most of the features above. The table lists what changed; the current approach is what the page teaches.

Older teaching or older code Current approach Since

"`ServeMux` cannot route by method or path variable, so use a third-party router" (Bodner ch. 11); prefix-only routing (Vivien ch. 11)

Patterns such as GET /books/{isbn}, {path...} and {$}, with r.PathValue; conflicts panic at registration

Go 1.22

Transport.Dial (Vivien ch. 11)

Transport.DialContext with a net.Dialer

Go 1.7 (Dial deprecated)

A server started with http.ListenAndServe and no timeouts (Vivien ch. 11); DefaultServeMux

Your own mux and an http.Server with ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout and Shutdown (Bodner ch. 11 gives the same advice)

Shutdown Go 1.8

A handler that writes the status after the body, sets no Content-Type, accepts any method and body size and never closes the file it serves (Vivien ch. 11)

Headers, then WriteHeader, then the body; explicit Content-Type; method-specific patterns; MaxBytesReader; http.ServeFileFS and FileServerFS with embed

FileServerFS Go 1.22

ioutil.ReadAll and the other io/ioutil functions (both books)

io.ReadAll, os.ReadFile, io.NopCloser

Go 1.16 (io/ioutil deprecated)

golint (both books)

go vet, including the httpresponse analyzer shown above

golint is archived

Before Go 1.25: token libraries for CSRF

http.CrossOriginProtection as a middleware

Go 1.25

Trailing-slash redirects with 301

307, which preserves the method

Go 1.26

Testing a server with a real port and sleeps

httptest.NewRecorder, NewServer, and NewTestServer with testing/synctest

NewTestServer Go 1.27

net.IP slices for addresses

net/netip value types

Go 1.18

Beyond the technical changes, two outdated practices from the older HTTP chapters are not repeated here: a handler that calls WriteHeader after writing the body, and a server started with http.ListenAndServe and no timeouts. The page shows the failure of the first with the real server log message, and replaces the second with the configuration above.

Common mistakes

Mistake Fix

Using http.DefaultClient or http.Get in production

A shared http.Client with Timeout, a configured Transport, and a request context.

Creating a new http.Client or Transport per request

One client per backend, reused; set MaxIdleConnsPerHost for busy backends.

defer resp.Body.Close() before checking err

Check err first (go vet: using resp before checking for errors).

Treating a 404 or 500 as success because err == nil

Check resp.StatusCode; wrap non-2xx in an error type.

Reading an unbounded response or request body

io.LimitReader on the client, http.MaxBytesReader on the server.

http.ListenAndServe with no timeouts, or a Server with WriteTimeout shorter than the slowest handler

An explicit http.Server; ResponseController for the few slow endpoints.

Calling WriteHeader after Write, or setting headers after either

Headers, then status, then body; the server logs superfluous response.WriteHeader call.

Registering routes on http.DefaultServeMux, and importing net/http/pprof into a public server

A private ServeMux; mount diagnostics on an internal listener.

Two patterns that overlap without one being more specific

It panics at start-up: add the method or a more specific literal to one of them.

Using a PathValue as a file name or SQL fragment

Validate it; use os.Root, a whitelist or SQL placeholders (see the database/sql page).

A state-changing GET handler

GET, HEAD and OPTIONS must be safe; use POST, PUT, PATCH or DELETE, which CrossOriginProtection guards.

Wrapping ResponseWriter without Unwrap

Add Unwrap() http.ResponseWriter; call http.NewResponseController(w).Flush() instead of asserting http.Flusher.

Exiting on SIGTERM without draining

signal.NotifyContext plus Server.Shutdown with its own timeout; wait for Shutdown, not Serve.

Passing the signal context as BaseContext

It cancels every in-flight request at once; give the server a context you cancel after the drain.

time.Sleep retry loops that ignore the context

select on ctx.Done() and a timer, as in the retry helper.

Storing the request or its context after the handler returns

Copy what you need; the context is cancelled when ServeHTTP returns. Use context.WithoutCancel for work that must outlive the request, deliberately.

Version notes

  • Go 1.7: Request.Context, Transport.DialContext; Transport.Dial deprecated.

  • Go 1.8: Server.Shutdown.

  • Go 1.13: Server.BaseContext and ConnContext.

  • Go 1.16: io/ioutil deprecated; io.ReadAll and io.NopCloser replace its functions.

  • Go 1.18: net/netip.

  • Go 1.20: http.ResponseController; httputil.ReverseProxy.Rewrite.

  • Go 1.21: ResponseController.EnableFullDuplex.

  • Go 1.22: ServeMux method and wildcard patterns, Request.PathValue and SetPathValue; ServeFileFS, FileServerFS and NewFileTransportFS; per-iteration loop variables make handler closures in loops safe; an empty Content-Length header is rejected unless GODEBUG=httplaxcontentlength=1.

  • Go 1.23: Request.Pattern; spaces or tabs allowed after the method in patterns; ParseCookie, ParseSetCookie, Cookie.Partitioned and Cookie.Quoted, Request.CookiesNamed; ServeContent and friends drop caching and encoding headers on errors; httptest.NewRequestWithContext.

  • Go 1.24: Server.Protocols, Transport.Protocols, HTTP2 configuration, unencrypted HTTP/2; os.Root; b.Loop and t.Context for tests.

  • Go 1.25: http.CrossOriginProtection; testing/synctest generally available.

  • Go 1.26: ServeMux redirects use status 307; Transport.NewClientConn; HTTP2Config.StrictMaxConcurrentRequests; the client honours Request.Host for cookies; ReverseProxy.Director deprecated; httptest.Server.Client redirects example.com; net/url.Parse rejects extra colons in hosts.

  • Go 1.27: httptest.NewTestServer (in-memory network, synctest-ready); HTTP/1 Response.Body.Close drains unread data up to a limit; Server.MaxHeaderValueCount; Server.DisableClientPriority and RFC 9218 priority in the HTTP/2 server; ALPN negotiation on custom connections.

  • Go 1.28 (upcoming, not released at the time of writing): the draft notes list url.MustParse and an HTTP QUERY method, among others; treat them as upcoming and re-check when it ships.