Go path · Many things at once · Topic 13 of 13

How net/http serves a request

Not started

The standard library's HTTP server is small enough to understand and good enough for production. Server.Serve sits in a loop accepting connections and starts a new goroutine for each one; that goroutine reads requests off the connection and calls your Handler. Everything else, from routers to middleware, is a Handler wrapping another Handler. Because each connection has its own goroutine and the scheduler parks it while it waits on the network, a single process can hold many thousands of mostly idle connections.

Why it matters: Most Go services are net/http underneath, including the popular routers. Knowing its concurrency model and its defaults explains slow-client outages, leaked goroutines and how graceful shutdown works.

  1. 1One goroutine per connection

    Serve accepts connections in a loop and starts a goroutine for each. Your handler runs on it, so handlers must be safe to run concurrently.

  2. 2ServeMux patterns since Go 1.22

    Patterns can include a method and wildcards, such as GET /items/{id}. r.PathValue reads the wildcard.

  3. 3Default timeouts are none

    A zero-value http.Server never times out a slow client. Set ReadHeaderTimeout at least, and usually read, write and idle timeouts too.

  4. 4The request carries a context

    r.Context() is cancelled when the client disconnects or the handler returns. Pass it to every downstream call.

1

How it works

The explanation, with examples you can run.

Routing with ServeMux

package main import (	"fmt"	"io"	"net/http"	"net/http/httptest") func main() {	mux := http.NewServeMux()	mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {		fmt.Fprintf(w, "item %s\n", r.PathValue("id")) // Go 1.22+ patterns	}) 	srv := httptest.NewServer(mux) // a real listener on 127.0.0.1	defer srv.Close() 	for _, req := range []struct{ method, path string }{		{"GET", "/items/42"}, {"POST", "/items/42"}, {"GET", "/nope"},	} {		r, _ := http.NewRequest(req.method, srv.URL+req.path, nil)		resp, err := http.DefaultClient.Do(r)		if err != nil {			panic(err)		}		body, _ := io.ReadAll(resp.Body)		resp.Body.Close()		fmt.Printf("%s %s -> %d %q\n", req.method, req.path, resp.StatusCode, body)	}}

Output: GET /items/42 -> 200 "item 42\n", POST /items/42 -> 405 "Method Not Allowed\n" and GET /nope -> 404 "404 page not found\n". Go 1.22 added methods and {name} wildcards to patterns (and {path...} for the rest of a path). When two patterns match, the more specific one wins, and conflicting registrations panic at start-up. For many services this removes the need for a third-party router.

What a handler should know

  • It runs concurrently with other handlers. Anything shared, such as a cache map or a counter, needs synchronisation.
  • r.Context() is cancelled when the client disconnects, when an HTTP/2 stream is reset, or when ServeHTTP returns. Pass it to database and HTTP calls so abandoned work stops.
  • Panics are recovered per request and logged, so one bad request doesn't kill the server. Goroutines the handler starts are not protected.
  • Always close response bodies on the client side, as above, or connections can't be reused.

Middleware

Because a Handler is just an interface with one method, middleware is a function that takes a handler and returns one: func logging(next http.Handler) http.Handler. It can inspect the request, call next.ServeHTTP(w, r), and look at what happened afterwards. Authentication, request IDs, metrics and panic reporting are all written this way, and r.WithContext lets middleware attach values or a deadline for the handlers below it.

Timeouts and shutdown

A zero http.Server has no timeouts, so a client that sends its headers one byte a minute keeps a goroutine and a file descriptor forever. Configure them:

srv := &http.Server{	Addr:              ":8080",	Handler:           mux,	ReadHeaderTimeout: 5 * time.Second,	ReadTimeout:       30 * time.Second,	WriteTimeout:      30 * time.Second,	IdleTimeout:       2 * time.Minute,}

For per-handler limits, http.TimeoutHandler wraps a handler, and http.NewResponseController (Go 1.20) can extend a deadline for one streaming response.

On SIGTERM, call srv.Shutdown(ctx): it closes the listeners, closes idle connections, and waits for active requests to finish or for ctx to expire. ListenAndServe then returns http.ErrServerClosed, which is the normal case, not a failure. The client side has the same issue: http.DefaultClient has no overall timeout, so build an http.Client with Timeout or use request contexts.

2

Check yourself

3 questions. Get them all right to finish this topic.

  1. 1.Two requests arrive at the same moment. How does net/http run your handler?
  2. 2.With mux.HandleFunc("GET /items/{id}", h), what does POST /items/42 return?
  3. 3.What does srv.Shutdown(ctx) do?

0 of 3 right so far. Get all three to finish this topic.