Mert Özen Further With Every Line
Backend Backend with Go — #09

HTTP Servers with net/http: ServeMux Pattern Routing, Handlers, Middleware and Timeouts

Mert Özen Nov 15, 2026 13 min 6 views
HTTP Servers with net/http: ServeMux Pattern Routing, Handlers, Middleware and Timeouts

For an HTTP service in Go the standard library is enough: since 1.22 ServeMux matches method and wildcard patterns with a most-specific-wins rule, and middleware is an ordinary function that wraps a handler. Moving the Bookstore API into the httpapi package, RequestID and Logging middleware, CORS and CrossOriginProtection, the 307 redirect, the pattern conflict panic, WriteTimeout not stopping the handler, and http.Server timeouts against slowloris.

Version note: The code in this post was built and run with Go 1.27.4; the module's go line is 1.26.0 and the code also compiles with Go 1.26.11. Method and wildcard patterns in ServeMux arrived in Go 1.22, http.CrossOriginProtection in Go 1.25, and the trailing slash redirect became a 307 in Go 1.26.

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, a back office report takes 2 seconds. We run the server as an http.Server whose WriteTimeout is 1 second:

func routes() *http.ServeMux {
    slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        time.Sleep(2 * time.Second) // a report that takes too long
        fmt.Fprintln(w, "report ready")
    })
    mux := http.NewServeMux()
    mux.Handle("GET /slow", slow)
    mux.Handle("GET /slow-guarded", http.TimeoutHandler(slow, 500*time.Millisecond, "report timed out\n"))
    return mux
}
srv := &http.Server{
    Addr:              addr,
    Handler:           routes(),
    ReadHeaderTimeout: 2 * time.Second,
    WriteTimeout:      1 * time.Second,
}
log.Fatal(srv.ListenAndServe())

What does the client get when it calls /slow: a 503 after 1 second, a 200 after 2 seconds, or a 500?

go run ./labs/09-timeouts server
curl -sS -i http://127.0.0.1:52711/slow
curl: (52) Empty reply from server

None of them. The handler kept running for 2 seconds and wrote its response, but since the write deadline had passed, that response never reached the client; the connection closed empty. WriteTimeout does not stop the handler, it only limits the time in which the response can be written. /slow-guarded, which wraps the same handler with http.TimeoutHandler, returned a proper 503 Service Unavailable after 500 ms. In this post we look at net/http's handler model, ServeMux pattern routing, the middleware chain and why http.ListenAndServe is not enough for production, using the Bookstore API.

Handler, HandlerFunc and ServeMux

The whole net/http model rests on a single-method interface: http.Handler, that is ServeHTTP(w http.ResponseWriter, r *http.Request). http.HandlerFunc is a type that turns an ordinary function with that signature into a Handler. A ServeMux is a Handler as well: it looks at the path of the incoming request and routes it to the right handler. Since post 01 we had written the handlers as closures inside main; as the number of endpoints grew, I moved them to methods of an API type in internal/httpapi:

// API holds what the handlers need. main builds it once.
type API struct {
    Books catalog.Store
}

// Routes registers every endpoint on a new ServeMux.
func (a *API) Routes() *http.ServeMux {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /hello", a.hello)
    mux.HandleFunc("GET /api/books", a.listBooks)
    mux.HandleFunc("GET /api/books/{isbn}", a.getBook)
    mux.HandleFunc("GET /api/authors", a.listAuthors)
    return mux
}
func (a *API) getBook(w http.ResponseWriter, r *http.Request) {
    book, err := a.Books.ByISBN(r.PathValue("isbn"))
    if errors.Is(err, catalog.ErrBookNotFound) {
        http.Error(w, err.Error(), http.StatusNotFound)
        return
    }
    if err != nil {
        log.Printf("get book: %v", err)
        http.Error(w, "internal server error", http.StatusInternalServerError)
        return
    }
    writeJSON(w, book)
}

API is the counterpart of the constructor that holds a controller's dependencies in ASP.NET Core; there is no DI container, main builds it by hand. cmd/bookstore-api/main.go now only builds the dependencies and starts the server. If you think of Bookstore.Api on the C# side, internal/httpapi corresponds to that project's controller layer in Go.

Pattern Routing: Methods, Wildcards and Precedence

Before Go 1.22 ServeMux only knew path prefixes; for method checks and path parameters you needed a router such as chi. With 1.22 patterns gained methods, wildcards and a few special forms. I tried all of them in a lab:

// Lab 09: ServeMux patterns, precedence, wildcards and redirects.
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func reply(name string) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "%s isbn=%q rest=%q\n", name, r.PathValue("isbn"), r.PathValue("rest"))
    }
}

func main() {
    mux := http.NewServeMux()
    mux.Handle("GET /api/books/{isbn}", reply("book"))
    mux.Handle("GET /api/books/bestsellers", reply("bestsellers"))    // more specific: wins
    mux.Handle("GET /covers/{rest...}", reply("cover"))               // matches the rest of the path
    mux.Handle("GET /docs/", reply("docs"))                           // trailing slash: a subtree
    mux.Handle("GET /api/books/{isbn}/reviews/{$}", reply("reviews")) // {$}: exact match only

    if len(os.Args) > 1 && os.Args[1] == "conflict" {
        // Neither pattern is more specific than the other: Handle panics.
        mux.Handle("GET /api/{kind}/9780441172719", reply("conflict"))
    }

    addr := "127.0.0.1:52711"
    log.Printf("routing lab on http://%s", addr)
    log.Fatal(http.ListenAndServe(addr, mux))
}
curl http://127.0.0.1:52711/api/books/9780441172719
curl http://127.0.0.1:52711/api/books/bestsellers
curl http://127.0.0.1:52711/covers/2026/dune.jpg
curl http://127.0.0.1:52711/api/books/9780441172719/reviews/
curl http://127.0.0.1:52711/api/books/9780441172719/reviews/latest
curl -i http://127.0.0.1:52711/docs
book isbn="9780441172719" rest=""
bestsellers isbn="" rest=""
cover isbn="" rest="2026/dune.jpg"
reviews isbn="9780441172719" rest=""
404 page not found
HTTP/1.1 307 Temporary Redirect
Content-Type: text/html; charset=utf-8
Location: /docs/

/api/books/bestsellers matches both patterns, and the registration order does not matter: the more specific pattern, the one that matches fewer requests, wins. {rest...} takes the rest of the path as a single value, and {$} requires that nothing follows the trailing slash. /docs/, ending in a slash, is a subtree pattern; a client asking for /docs is redirected to /docs/. Since Go 1.26 this redirect is a 307 (Temporary Redirect) instead of a 301. Because browsers can cache a 301 permanently, adding a separate handler for /docs later could cause trouble.

Two patterns where neither is more specific than the other cause a panic at registration time:

go run ./labs/09-routing conflict
panic: pattern "GET /api/{kind}/9780441172719" (registered at /home/user/bookstore/labs/09-routing/main.go:27) conflicts with pattern "GET /api/books/{isbn}" (registered at /home/user/bookstore/labs/09-routing/main.go:19):
    GET /api/{kind}/9780441172719 and GET /api/books/{isbn} both match some paths, like "/api/books/9780441172719".
    But neither is more specific than the other.
    GET /api/{kind}/9780441172719 matches "/api/kind/9780441172719", but GET /api/books/{isbn} doesn't.
    GET /api/books/{isbn} matches "/api/books/isbn", but GET /api/{kind}/9780441172719 doesn't.

That the error shows up when the server starts rather than at run time is good design: an ambiguous route can never reach production. The route templates in the ASP.NET Core Web API series' "Routing" post worked with a similar priority logic; in Go the rule is simpler and single: the more specific pattern wins, and a tie is a panic.

In the Bookstore API a request with the wrong method gets a 405, and the Allow header lists the permitted methods. A GET pattern also serves HEAD requests:

curl -i -X POST http://127.0.0.1:52710/api/books/9780141439587
HTTP/1.1 405 Method Not Allowed
Allow: GET, HEAD
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Request-Id: 2MGGJ6LL4XRP
Date: Sun, 15 Nov 2026 01:48:08 GMT
Content-Length: 19

Method Not Allowed

Middleware: A Function That Wraps a Handler

In Go middleware is not a special concept: it is a function that takes an http.Handler and returns another http.Handler. Recover from post 05 was one. To connect several middleware in a readable order I wrote a small Chain function and added two new middleware:

// Middleware wraps a handler with extra behaviour.
type Middleware func(http.Handler) http.Handler

// Chain wraps h so that the first middleware in the list runs first.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- {
        h = mws[i](h)
    }
    return h
}

type requestIDKey struct{}

// RequestID gives every request an ID: the client's X-Request-ID if it sent
// a short one, a random one otherwise. The ID goes into the context and the
// response header.
func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-ID")
        if id == "" || len(id) > 64 {
            id = rand.Text()[:12]
        }
        w.Header().Set("X-Request-ID", id)
        ctx := context.WithValue(r.Context(), requestIDKey{}, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// RequestIDFrom returns the ID that RequestID stored in ctx.
func RequestIDFrom(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey{}).(string)
    return id
}
// statusRecorder remembers the status code and body size a 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(b []byte) (int, error) {
    if s.status == 0 {
        s.status = http.StatusOK
    }
    n, err := s.ResponseWriter.Write(b)
    s.bytes += n
    return n, err
}

// Unwrap lets http.ResponseController reach the original writer.
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }

// Logging writes one line per request after the handler returns.
func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w}
        next.ServeHTTP(rec, r)
        log.Printf("%s %s %d %dB %v rid=%s", r.Method, r.URL.RequestURI(), rec.status, rec.bytes,
            time.Since(start).Round(time.Microsecond), RequestIDFrom(r.Context()))
    })
}

Logging wraps the ResponseWriter in a statusRecorder to know which status code the handler wrote. The Unwrap method is a small but important detail: http.ResponseController from Go 1.20 uses it to reach the writer underneath a wrapped one, for jobs such as flushing or setting a write deadline for a single request. RequestID is the typical use of the context values we discussed in post 08: it puts the ID into the context under an unexported key and lets RequestIDFrom read it. I also added this ID to Recover's log line.

09-middleware-chain-en.png

Order matters. RequestID is outermost because Logging and Recover use the ID. Logging is outside Recover because we also want to log the 500 written after a panic. In the ASP.NET Core Web API series' "ASP.NET Core Middleware Pipeline" post we discussed why the order of app.Use matters; in Go the same order is the argument order of Chain. The mechanics match too: in ASP.NET Core each middleware decides whether to call next(), and in Go that is whether it calls next.ServeHTTP(w, r).

CORS and CrossOriginProtection

During development Bookstore's Angular client runs at http://localhost:4200, while the API is on another port. So the browser runs a CORS check on every request to the API and sends an OPTIONS preflight request before a POST. The CORS middleware only answers the allowed origin and finishes the preflight itself without sending it to the mux:

// CORS lets the Angular app at origin call the API from the browser.
func CORS(origin string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if r.Header.Get("Origin") != origin {
                next.ServeHTTP(w, r)
                return
            }
            h := w.Header()
            h.Set("Access-Control-Allow-Origin", origin)
            h.Add("Vary", "Origin")
            if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" {
                h.Set("Access-Control-Allow-Methods", "GET, POST")
                h.Set("Access-Control-Allow-Headers", "Content-Type, X-Request-ID")
                h.Set("Access-Control-Max-Age", "600")
                w.WriteHeader(http.StatusNoContent) // preflight: answer here, skip the mux
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

http.CrossOriginProtection from Go 1.25 is a token-free defence against CSRF attacks; in main I added Angular's origin as trusted:

csrf := http.NewCrossOriginProtection()
if err := csrf.AddTrustedOrigin(angularDevOrigin); err != nil {
    log.Fatal(err)
}

For requests other than GET, HEAD and OPTIONS it looks at the Sec-Fetch-Site header the browser sends; if that header is missing, it compares Origin with Host. If both are missing, it assumes the request did not come from a browser and lets it through; curl and other services are not affected. We do not have a POST endpoint yet, but the check is already in place for the /api/orders coming in post 10:

curl -i -X OPTIONS -H 'Origin: http://localhost:4200' -H 'Access-Control-Request-Method: POST' http://127.0.0.1:52710/api/orders
curl -i -X POST -H 'Sec-Fetch-Site: cross-site' -H 'Origin: https://evil.example.com' http://127.0.0.1:52710/api/orders
HTTP/1.1 204 No Content
Access-Control-Allow-Headers: Content-Type, X-Request-ID
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Origin: http://localhost:4200
Access-Control-Max-Age: 600
Vary: Origin
X-Request-Id: 5HL2X5DJIFGP
Date: Sun, 15 Nov 2026 01:48:08 GMT

HTTP/1.1 403 Forbidden
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
X-Request-Id: AYT54Q7ZF43N
Date: Sun, 15 Nov 2026 01:48:08 GMT
Content-Length: 57

cross-origin request detected from Sec-Fetch-Site header

The server log shows every request on one line; the ID on the second line is the X-Request-ID the client sent itself:

2026/11/15 04:48:08 GET /api/books/9780141439587 200 87B 232µs rid=OKZYOUVJJVND
2026/11/15 04:48:08 GET /api/books/9780000000000 404 35B 20µs rid=angular-7f3a
2026/11/15 04:48:08 POST /api/books/9780141439587 405 19B 25µs rid=2MGGJ6LL4XRP
2026/11/15 04:48:08 OPTIONS /api/orders 204 0B 11µs rid=5HL2X5DJIFGP
2026/11/15 04:48:08 POST /api/orders 403 57B 8µs rid=AYT54Q7ZF43N

http.Server and Timeouts

http.ListenAndServe(addr, h) opens an http.Server whose timeouts are all zero, and zero means "no limit". The lab's slowloris mode shows this situation, well known since Cloudflare's 2016 post "The complete guide to Go net/http timeouts": the client sends the request line and one header, and never sends the blank line that ends the headers.

case "slowloris":
    conn, err := net.Dial("tcp", addr)
    if err != nil {
        log.Fatal(err)
    }
    defer conn.Close()
    start := time.Now()
    fmt.Fprint(conn, "GET /slow HTTP/1.1\r\nHost: bookstore\r\n") // no blank line: headers never end
    if err := conn.SetReadDeadline(time.Now().Add(8 * time.Second)); err != nil {
        log.Fatal(err)
    }
    reply, err := io.ReadAll(conn)
    fmt.Printf("after %v: %d bytes, err=%v\n", time.Since(start).Round(100*time.Millisecond), len(reply), err)
    if len(reply) > 0 {
        fmt.Printf("%q\n", reply)
    }

I started the server in one terminal first in plain mode, then in server mode, and ran the client in the other terminal each time:

go run ./labs/09-timeouts plain
go run ./labs/09-timeouts slowloris
go run ./labs/09-timeouts server
go run ./labs/09-timeouts slowloris
after 8s: 0 bytes, err=read tcp 127.0.0.1:63786->127.0.0.1:52711: i/o timeout
after 2s: 0 bytes, err=<nil>

The server opened with ListenAndServe kept the connection open for 8 seconds, and the side that gave up was the client. Someone opening thousands of such connections, each holding a goroutine and a file descriptor, can lock up the server; this attack is called slowloris. The server whose ReadHeaderTimeout is 2 seconds closed the connection after 2 seconds.

The Bookstore API's main now builds the chain and an http.Server with its timeouts set:

func main() {
    api := &httpapi.API{Books: catalog.Store{}}

    csrf := http.NewCrossOriginProtection()
    if err := csrf.AddTrustedOrigin(angularDevOrigin); err != nil {
        log.Fatal(err)
    }

    handler := httpapi.Chain(api.Routes(),
        httpapi.RequestID, // outermost: runs first, sees every request
        httpapi.Logging,
        httpapi.Recover,
        httpapi.CORS(angularDevOrigin),
        csrf.Handler, // innermost: right before the mux
    )

    srv := &http.Server{
        Addr:              "127.0.0.1:52710",
        Handler:           handler,
        ReadHeaderTimeout: 5 * time.Second,  // request line and headers
        ReadTimeout:       10 * time.Second, // headers and body
        WriteTimeout:      15 * time.Second, // from the end of the headers to the end of the response
        IdleTimeout:       60 * time.Second, // keep-alive connection waiting for the next request
        MaxHeaderBytes:    64 << 10,
    }
    log.Printf("bookstore-api listening on http://%s", srv.Addr)
    log.Fatal(srv.ListenAndServe())
}

The four timeouts cover different things. ReadHeaderTimeout limits reading the request line and headers, ReadTimeout limits reading the whole request including the body. WriteTimeout limits the time from the end of reading the headers to the end of writing the response; as we saw in Guess First, it does not stop the handler. IdleTimeout is how long a keep-alive connection waits for the next request; if it is zero, ReadTimeout is used. If you really want to limit a long-running handler, use http.TimeoutHandler or context.WithTimeout inside the handler; for long responses such as file downloads you can extend that request's write deadline separately with http.ResponseController. Go 1.27 also added MaxHeaderValueCount, which limits how many header values the server accepts in a request; the default is 500.

chi, Gin, Echo: When?

After the pattern routing in 1.22 the standard library is enough for a small or medium-sized API; that is why this series uses no framework. chi is a router that keeps the standard http.Handler signature: it has route groups, sub-routers and ready-made middleware, and any middleware you write also works with net/http. If you need many route groups or ready-made middleware, it is the first place to look. Gin and Echo bring their own Context types; your handlers and middleware get tied to that framework, and you end up with a conversion layer between it and the standard library ecosystem. If the team already uses one, that is fine; in a new project, if routing is the only reason, it is no longer needed.

In Production

Choose timeouts for what the application really needs. An endpoint that accepts file uploads will not work with a 10-second ReadTimeout; move such endpoints to a separate server or to a per-request deadline with ResponseController. The server-level values are there to limit attacks, not the slowest legitimate request.

CORS is not a security mechanism for the server; it is the browser's mechanism for protecting its own user. curl or another service never looks at CORS. Always do authorization on the server side and read the list of allowed origins from configuration; hard-coding localhost:4200 is only for development. In post 13 we will move addresses into configuration.

Return the request ID from the log to the client too. If the Angular application can show the X-Request-Id when a user reports an error, you find that request in the log within seconds. When we switch to log/slog in post 13, this ID will become a field of the structured log.

Common Pitfalls

http.ListenAndServe in production. As we saw in the lab, there is no timeout at all. Always build your own http.Server and set at least ReadHeaderTimeout.

Taking WriteTimeout for a handler timeout. The handler keeps running and the client gets an empty reply. Use TimeoutHandler or a context to limit how long a handler runs.

Building the middleware order the wrong way round. If you put Recover outside Logging, the log line of a request that panics is never written, because the panic goes up before reaching Logging's log line.

Changing headers after WriteHeader. Once the status code is written, w.Header().Set(...) has no effect. That is why RequestID sets its header before calling next.

Using the global ServeMux. http.HandleFunc registers on the package-level http.DefaultServeMux; any package you import can add routes there too. As in Bookstore, always build your own mux with http.NewServeMux().

A Small Experiment

★☆☆ Add the pattern GET /api/books/{isbn}/reviews/{id} to labs/09-routing. Which handler answers a request for /api/books/9780441172719/reviews/? Why?

★★☆ Change Logging so that it writes WARN at the start of the log line for responses of 400 and above. Then change the order in Chain, move Recover to the outermost position, and see what happens to the log of a request that panics.

★★★ Write the /slow handler so that it listens to r.Context() and stops as soon as the TimeoutHandler's time is up. Hint: instead of time.Sleep, wait on time.After and r.Context().Done() together with select; verify that TimeoutHandler cancels the request's context.

The skeleton of the API is ready. In the next post we move on to JSON and request validation: struct tags, body limits with DisallowUnknownFields and MaxBytesReader, encoding/json/v2 which joined the standard library in Go 1.27, returning errors as Problem Details (RFC 9457), and finally taking Bookstore's first order over HTTP with POST /api/orders.