The Standard Library Underneath All of This

net/http from the ground up for the reader who knows Go but has not built a service with it: listeners, handlers, the concurrency model, request context, and middleware built once by hand.


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.

Layer diagram of the Go standard library HTTP stack, from the bound socket at the bottom to the handler at the top. The layers are named in the sections below.

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.