ToolPopToolPop
Go · Lesson 18 of 20

net/http patterns: middleware, graceful shutdown, request scoping

9 min readUpdated 25 Jun 2026

The Go standard library gives you a production-grade HTTP server in net/http. Not a toy, not a starting point you graduate from. The same package powers Cloudflare, Docker, Kubernetes, and most of the cloud-native ecosystem. If you understand the four patterns in this lesson, you will not need a web framework for 90 percent of services.

The framework-shopping habit is a Node.js mindset. In Go, the standard library is the framework, and adding a router like chi or gin is an optimisation, not a foundation. The senior interview signal is that you can build a real service on the stdlib, articulate which corners benefit from a router, and resist over-engineering when the corners do not apply.

The Handler interface

Everything in net/http reduces to one interface:

go
type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

A handler reads from r, writes to w. That is the whole abstraction. http.HandlerFunc is a one-line adapter that lets a plain function satisfy the interface:

go
type HandlerFunc func(http.ResponseWriter, *http.Request)
 
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) }

This adapter is why you can pass either a struct with a ServeHTTP method or a bare function to mux.Handle. Two styles, one contract.

HandleFunc vs NewServeMux

http.HandleFunc registers on the global default mux. http.NewServeMux gives you a local mux you own.

go
// Implicit global, fine for examples, dangerous in libraries
http.HandleFunc("/health", healthHandler)
 
// Explicit local, the right answer for any real service
mux := http.NewServeMux()
mux.HandleFunc("/health", healthHandler)
mux.HandleFunc("/users/{id}", usersHandler) // 1.22+ path params

Senior rule: never register on the default mux in production. If any library you import also registers there (and net/http/pprof does), your routes collide and the failure is silent.

Since Go 1.22, the standard mux supports method-aware routing and path parameters:

go
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
 
func getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")
    // ...
}

For 90 percent of services this is enough. No gorilla/mux, no chi, no gin. Reach for a router library when you need middleware groups, regex routes, or sub-routers.

Middleware: a function on functions

Middleware in Go is a function that takes a Handler and returns a Handler. That is the whole concept.

go
type Middleware func(http.Handler) http.Handler
 
func logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}
 
func auth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Authorization") == "" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Chaining is composition. The conventional idiom is to wrap from inside out:

go
handler := logging(auth(mux))
http.ListenAndServe(":8080", handler)

Reading order is reverse-execution order: mux runs last, auth runs around it, logging runs around both. Some teams wrap this in a Chain(...) helper, but the bare composition is usually clearer than the abstraction.

Diagram
rendering diagram...
Request flow: middleware stack wraps the handler, context propagates through

Request-scoped context

Every *http.Request carries a context.Context accessible via r.Context(). This is the canonical place to thread request IDs, tracing spans, authenticated user objects, and deadlines.

go
type ctxKey string
 
const requestIDKey ctxKey = "request-id"
 
func requestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := uuid.NewString()
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-ID", id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}
 
func RequestIDFrom(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey).(string)
    return id
}

The unexported ctxKey type prevents collisions with other packages that also use string keys. Always define a typed key, never a bare string.

Interview trap: context.WithValue is NOT a general-purpose request-scoped dependency injection. Use it for cross-cutting metadata (request ID, trace span, auth principal). Pass real dependencies (database handles, config) through constructor injection instead.

Graceful shutdown

A production server must drain in-flight requests when SIGTERM arrives. Otherwise rolling deploys cut connections mid-response and your error rate spikes.

go
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })
 
    srv := &http.Server{
        Addr:              ":8080",
        Handler:           logging(requestID(mux)),
        ReadHeaderTimeout: 5 * time.Second,
        ReadTimeout:       30 * time.Second,
        WriteTimeout:      30 * time.Second,
        IdleTimeout:       120 * time.Second,
    }
 
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()
 
    go func() {
        log.Printf("listening on %s", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()
 
    <-ctx.Done()
    log.Println("shutting down")
 
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
 
    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Fatalf("shutdown: %v", err)
    }
    log.Println("server exited cleanly")
}

Walk through the lifecycle. signal.NotifyContext returns a context that cancels on SIGINT or SIGTERM. The server runs in a goroutine. The main goroutine blocks on <-ctx.Done() until a signal arrives. Then srv.Shutdown stops accepting new connections and waits for active ones to finish, bounded by the shutdown context. After 30 seconds it gives up and forces close.

The http.ErrServerClosed check matters. When Shutdown succeeds, ListenAndServe returns this sentinel error, which is the success path, not a failure.

The timeouts that matter

Go's http.Server defaults to NO timeouts. A misbehaving client can hold a connection forever, tying up one goroutine per connection. With enough such connections, your process runs out of file descriptors or memory.

The four timeouts:

  • ReadHeaderTimeout: how long to wait for the request headers. Without this, you are vulnerable to Slowloris.
  • ReadTimeout: how long to read the entire request including body.
  • WriteTimeout: how long to write the entire response.
  • IdleTimeout: how long to keep keepalive connections open between requests.

Senior rule: ALWAYS set ReadHeaderTimeout. Even if you have an upstream proxy that strips slow connections, set it in the application as defence in depth. Slowloris is a 15-year-old attack that still works against servers that forget this knob.

A useful rule of thumb: pick conservative server-wide values that cover 99 percent of your endpoints, then extend per-handler for the exceptions. A global WriteTimeout of 5 minutes to accommodate one upload endpoint means every other endpoint is also exposed to slow attackers for 5 minutes. The per-handler approach keeps the blast radius small.

For long-uploading or long-streaming endpoints, do not just bump WriteTimeout globally. Use http.ResponseController to extend the deadline for that one handler:

go
func upload(w http.ResponseWriter, r *http.Request) {
    rc := http.NewResponseController(w)
    rc.SetWriteDeadline(time.Now().Add(5 * time.Minute))
    // ... stream the file
}

HTTP server vocabulary

http.Handler
Interface with one method ServeHTTP(w, r). Everything routable in net/http satisfies it.
ServeMux
Router that maps URL patterns to handlers. Since 1.22, supports method-aware routes and path params.
middleware
Function that takes a Handler and returns a wrapped Handler. Composes via nested calls.
context.WithValue
Attaches a typed key/value to a context. For request-scoped metadata, not for dependency injection.
graceful shutdown
srv.Shutdown(ctx) stops accepting new connections and waits for active ones to finish, bounded by the provided context.
ReadHeaderTimeout
Maximum time the server will wait for request headers. Set this or you are vulnerable to Slowloris.

What we will cover next

The next lesson is testing. The patterns here (Handler, middleware, context) are also the easiest things in the Go ecosystem to unit test, because httptest.NewRecorder lets you call a handler in-process with zero infrastructure. We will use that, plus table-driven tests, subtests, and the race detector, to lock in correctness.

Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.

Try this (0 of 1 done)

  1. 1

    Predict what main prints (the ListenAndServe line is commented out).

    show answer
    // already in the editor