Table of contents
Go’s HTTP server is in the standard library, it is production quality, and six types do most of the work. Learn them here and most of what a framework, router or toolkit adds later reads as a layer over them.
This page is entirely net/http, net and context. No dependencies, no
toolkit, nothing from the guide’s reference implementation.
The same system as the picture on page 01, cut
the other way: stacked, with the standard library type that owns each layer. A
bound TCP socket is a net.Listener. An http.Server runs the accept loop, one
goroutine per connection. An http.ServeMux or a third-party router matches the
method and path. A chain of http.Handler wrappers runs in order. The
http.HandlerFunc at the top answers.
The smallest server, and what it hides
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
})
http.ListenAndServe(":8080", nil)
}
Three decisions are hidden in those six lines, and a real service has to make each
of them itself. http.HandleFunc registers on a package-level ServeMux shared
by everything in the process, including any library that also calls it.
http.ListenAndServe constructs an http.Server with no timeouts configured. The
returned error is discarded, so a failure to bind the port looks exactly like
success.
The version a service actually writes separates them out:
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", helloHandler)
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
lst, err := net.Listen("tcp", ":8080")
if err != nil {
return fmt.Errorf("cannot bind :8080: %w", err)
}
err = srv.Serve(lst)
Longer, and every line of the difference is something the short version decided for you.
Binding and serving are two acts
net.Listen asks the operating system for the port. When it returns without an
error the port is held: nothing else on the machine can take it, and a client
connecting to it completes a TCP handshake even though no code is reading yet.
srv.Serve(lst) starts the accept loop that turns those connections into
requests.
The gap between the two gives you a place to fail. A service that binds during
startup and returns an error exits before it has told an orchestrator it is ready.
A service that binds lazily inside ListenAndServe discovers the port is taken
after reporting itself healthy.
It also makes port :0 useful. Ask for port zero and the operating system assigns
a free one, which you read back from lst.Addr():
lst, _ := net.Listen("tcp", ":0")
addr := lst.Addr().String() // for example "127.0.0.1:41235"
An entire class of integration test follows. Bind to zero, start serving, point a
real http.Client at the assigned address, and you have exercised routing,
middleware and response encoding in a few milliseconds with no containers and no
fixed port to collide with. Page 18 builds one.
http.Handler is the whole interface
type Handler interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
One method. Routers, middleware, frameworks and handlers across the Go ecosystem are either this interface or something that produces it. Go web code composes across libraries that know nothing about each other.
A handler is a struct method when it needs dependencies:
type itemHandler struct {
svc ItemService
}
func (h *itemHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// ...
}
and a plain function when it does not. http.HandlerFunc is the adapter that
makes a function satisfy the interface:
type HandlerFunc func(w ResponseWriter, r *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}
A named type with a method on it, whose method calls the function it was converted
from. http.HandlerFunc(myFunc) is a conversion rather than a call, and it yields
a value that is an http.Handler. That trick appears constantly in Go HTTP code.
Routing
A router looks at the method and path and picks a handler. http.ServeMux is the
one in the standard library, and since Go 1.22 its patterns understand methods and
path wildcards:
mux := http.NewServeMux()
mux.HandleFunc("GET /items", listItems)
mux.HandleFunc("POST /items", createItem)
mux.HandleFunc("GET /items/{id}", getItem)
The captured segment comes back with r.PathValue("id").
A ServeMux is itself an http.Handler, which is the basis of every composition
trick later in the guide. Wrapping a router in middleware is the same operation as
wrapping a single handler. Mounting one router inside another is the same
operation again.
Page 07 covers what a third-party router adds over this one and what going back would cost.
The request
*http.Request carries the method, the URL, the headers, and the body.
func handle(w http.ResponseWriter, r *http.Request) {
r.Method // "POST"
r.URL.Path // "/items"
r.URL.Query().Get("page")
r.Header.Get("Content-Type")
r.Body // an io.ReadCloser
}
r.Body is a stream and reading it consumes it. If two pieces of code both want
the body, one has to buffer it and hand the bytes to the other. Middleware that
logs request bodies is doing exactly that, and needs a size limit for it.
r.URL.Query() parses the raw query string on every call and returns a fresh
url.Values. Call it once and reuse the result.
Header names are canonicalised. r.Header.Get("content-type") and
r.Header.Get("Content-Type") return the same value, and reading the map directly
with r.Header["content-type"] returns nothing. Use the accessor.
The response writer, and how it surprises people
type ResponseWriter interface {
Header() http.Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}
The order you call these in is load-bearing.
Headers must be set before anything is written. The status line and headers go
onto the wire the first time you call Write or WriteHeader. Any change to
w.Header() after that modifies a map nobody will read. The compiler will not
stop you, and the failure is a missing Content-Type in production and nothing in
the logs.
// Wrong. The header is set after the body has already gone out.
w.Write([]byte(`{"ok":true}`))
w.Header().Set("Content-Type", "application/json")
// Right.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"ok":true}`))
The status is 200 unless you say otherwise. The first Write without a
preceding WriteHeader sends 200 OK. A handler that hits an error halfway
through writing a body cannot take the 200 back, so error paths have to be decided
before the first byte. Build the response value completely, then serialise it in
one place. Page 10 is built on that.
WriteHeader is once per response. A second call logs “superfluous
WriteHeader call” to the server’s error log and does nothing else. That message
almost always means two layers both think they own the error response, which is an
ordering problem in the middleware chain.
There is a buffer under the writer, roughly a few kilobytes, and it is an
implementation detail rather than a promise. If you are streaming and want bytes
to leave now, assert http.Flusher and call Flush. For a JSON API answering in
one shot, ignore it.
The concurrency model
The server’s loop is close to this:
for {
conn, err := listener.Accept()
if err != nil {
// ...
}
go serveConnection(conn)
}
One goroutine per accepted connection. Within a connection, requests are handled one after another, because HTTP/1.1 keep-alive puts them in sequence on the same socket. Across connections there is no ordering at all. HTTP/2 splits each stream onto its own goroutine, so a single connection there can have many requests in flight.
Your handler function runs concurrently with itself. Not occasionally, not under load, always.
Everything a handler touches is either created per request, and private to it, or
created once at startup, and shared. The *http.Request, the
http.ResponseWriter, local variables and anything derived from them need no
synchronisation. The database pool, the HTTP clients, the logger, the metrics
client, the handler struct itself and every field on it have to be safe for
concurrent use. The standard library types designed for it say so in their
documentation: *sql.DB is safe, *http.Client is safe. A map you added to your
handler struct is not, and it fails as a runtime panic under concurrent load
rather than a compile error.
So a handler struct holds dependencies and nothing else. Per-request state goes in local variables or in the request context. A field on a handler that changes during a request is a data race waiting for enough traffic.
Context, and how a timeout travels
context.Context carries a deadline, a cancellation signal and request-scoped
values across API boundaries. In an HTTP service it is how a timeout at the top
reaches a query at the bottom.
Every request carries one:
ctx := r.Context()
The server cancels it when the client’s connection closes, when an HTTP/2 stream
is reset, and when ServeHTTP returns. Anything downstream watching ctx.Done()
learns that the caller has gone.
Without that: a client sends a request, waits two seconds, gives up and disconnects. Your handler is three layers down in a query that will take another forty. Nobody told the database, so it keeps working, holding a pool connection, for a response no one will read. Repeat under load and the pool is full of work for clients that left.
The fix is to pass the context down and let each layer honour it:
func (r *repo) Get(ctx context.Context, id string) (*Item, error) {
row := r.db.QueryRowContext(ctx, "SELECT ... WHERE id = ?", id)
// ...
}
QueryRowContext rather than QueryRow. When the context is cancelled the driver
sends a cancellation to the server and the call returns context.Canceled. The
same applies to http.NewRequestWithContext for outbound calls. Any function in
your own code that can block should take a context.Context first and pass it on.
Deadlines shorten as they travel:
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
The derived context fires whichever comes first, the two seconds or the parent’s cancellation. It never extends the parent: a budget set at the edge cannot be quietly enlarged three layers down. Page 09 follows that budget through a real request.
Contexts also carry values, with context.WithValue and ctx.Value(key). The
narrow, legitimate use is request-scoped data that every layer might want and no
layer should have to take as a parameter: a trace ID, an authenticated identity.
Use an unexported key type so no other package can collide with yours:
type ctxKey struct{}
ctx = context.WithValue(ctx, ctxKey{}, traceID)
id, _ := ctx.Value(ctxKey{}).(string)
Passing a database handle or business arguments this way trades a compile-time error for a runtime one. Function parameters exist.
Middleware, built once by hand
Middleware is a function that takes a handler and returns a handler. That is the entire idea:
func logging(l *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
l.Info("request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Duration("duration", time.Since(start)),
)
})
}
Before next.ServeHTTP you see the request on the way in. After it you are on the
way out. Wrapping is composition, so a chain is nesting:
handler := recovery(logging(l, traceID(mux)))
recovery is outermost, so it sees the request first and the response last, and
catches a panic raised anywhere inside. mux is innermost. Reading
a chain from the outside in is reading it in request order.
That expression does not give you the status code. next.ServeHTTP
returns nothing, and the handler inside wrote the status directly to w. To log
it, the middleware substitutes its own ResponseWriter:
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
Embedding http.ResponseWriter promotes Header and Write unchanged, and the
explicit WriteHeader records the code on its way through. Most toolkits provide
this, and it carries a cost worth knowing: the embedded value satisfies
http.ResponseWriter and loses every optional interface the original also
implemented, including http.Flusher and http.Hijacker. For a JSON API that is
invisible. For server-sent events or WebSocket upgrades it breaks them, and the
fix is to implement the missing interfaces on the wrapper as well.
The service in this guide is the code above with production concerns attached. A
listener bound early so failures are early. A router. A middleware chain. A
handler that owns dependencies and no per-request state. A context threaded
through every call that can block. When a later page introduces a package, hold it
against this: httpserver is net.Listen plus http.Server plus validation plus
shutdown choreography.
Next: From main to Listening.