Everything so far — errors, interfaces, goroutines, channels, context — comes together when you build the thing most backend engineers build in Go: an HTTP service. The pleasant surprise is how little you need beyond the standard library. net/http is production-grade on its own; for most services you do not reach for a framework at all. This lesson builds up a real service from handlers to middleware to the production details that matter.
The Standard Library Is the Framework
The core abstraction is the handler, and it is tiny — just a function:
func getOrder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // path param (Go 1.22+)
order, err := store.Get(r.Context(), id)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order) // stream JSON to the response
}A handler reads the request and writes the response. That is the whole contract: func(w http.ResponseWriter, r *http.Request). You write JSON out with json.NewEncoder(w).Encode(v) and read JSON in with json.NewDecoder(r.Body).Decode(&v) — streaming encoders that avoid building the whole payload in memory.
Routing is http.ServeMux. Since Go 1.22, the standard mux matches on method and path patterns with wildcards, which removes the main reason people used to reach for a third-party router:
mux := http.NewServeMux()
mux.HandleFunc("GET /orders/{id}", getOrder)
mux.HandleFunc("POST /orders", createOrder){id} is a path variable you read with r.PathValue("id"). For the vast majority of services this is all the routing you need — no gorilla/mux, no chi, no framework. (Those libraries still have their place for complex routing or middleware ecosystems, but start with the standard mux and add only if you feel real pain.)
Middleware: Composable Wrappers
Cross-cutting concerns — logging every request, checking auth, recovering from panics (Lesson 2), attaching a request ID — are handled with middleware, and Go’s middleware is beautifully simple: a middleware is a function that takes a handler and returns a handler. Same type in, same type out, so they compose:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // call the wrapped handler
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
// compose them as layers around the mux:
handler := Logging(Auth(Recover(mux)))Each middleware wraps the next, forming the onion in the diagram: a request passes in through logging → auth → recover → your handler, and the response passes back out through the same layers. Because every layer has the identical http.Handler shape, you stack them in any order and reuse them across routes. A panic-recovery middleware at the outer layer is essential (Lesson 2): it turns a bug in one handler into a logged 500 for that one request, instead of a crash that kills every in-flight request.
Context, Timeouts, and the Goroutine Per Request
Three production details separate a toy server from a real one.
Thread the context. Every request carries a context.Context via r.Context() (Lesson 6). It is cancelled when the client disconnects. Pass it into every downstream call — store.Get(r.Context(), id), db.QueryContext(ctx, ...) — so that when the client goes away, the DB query and API calls for that request stop too. Wrap it with a timeout to bound how long a request may take: ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second); defer cancel().
Set server timeouts — this is the one people forget. The zero-value http.Server has no timeouts at all, which means a single slow or malicious client can hold a connection open indefinitely, and enough of them exhaust your connections. Always construct the server with timeouts set:
srv := &http.Server{
Addr: ":8080",
Handler: handler,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
log.Fatal(srv.ListenAndServe())ReadTimeout bounds reading the request, WriteTimeout the response, IdleTimeout how long a keep-alive connection may sit idle. Never ship the timeout-less default. (We add graceful shutdown to this server in Lesson 11.)
One goroutine per request, free. The net/http server spawns a fresh goroutine for every incoming request (Lesson 4) and runs your handler in it. This is why you write your handler as simple, straight-line, blocking code — order := store.Get(...) that blocks on the database is completely fine, because it blocks only that request’s goroutine; the server keeps serving everyone else on other goroutines. You get the throughput of an async server with the readability of synchronous code, and you never write a callback. It also means your handler must be safe to run concurrently with copies of itself — any shared state it touches needs the synchronisation from Lesson 7.
With those pieces — standard mux for routing, handlers for logic, middleware for cross-cutting concerns, context threaded through, timeouts set, and one goroutine per request — you have the shape of essentially every Go HTTP service. It is remarkably little code, nearly all standard library, and it scales because the runtime does the concurrency for you.
Pitfalls to Avoid
- Running the zero-value server (no timeouts).
http.ListenAndServe(":8080", h)and the bare&http.Server{}have no read/write/idle timeouts; one slow client can hold a connection forever. Always setReadTimeout,WriteTimeout,IdleTimeout. - Ignoring
r.Context(). Not passing the request context into DB and API calls means a disconnected client’s work keeps running. Threadr.Context()(wrapped with a timeout) into every downstream call. - No panic-recovery middleware. A nil-pointer bug in one handler panics the goroutine and, without a top-level
recover, can crash the process. Add a recover middleware that logs and returns 500 (Lesson 2). - Not checking the JSON decode error, or not limiting body size.
json.NewDecoder(r.Body).Decode(&v)can fail or be fed a huge body. Check the error, and wrap the body withhttp.MaxBytesReaderto cap request size. - Writing the status code after the body. Call
w.WriteHeader(code)(orhttp.Error) before writing the body; once you write bytes, the status is locked to 200. Set headers, then status, then body — andreturnafter an error response so you don’t also write a success body. - Reaching for a framework reflexively. Importing a big framework for a service that
net/http+ the 1.22 mux handles fine adds dependencies and concepts for no gain. Start with the standard library; add a router only when you hit a real limitation.
Quick Recap
- In Go the standard library is the framework:
net/httpserves production traffic with no third-party router for most services. - A handler is just
func(w http.ResponseWriter, r *http.Request). Route withhttp.ServeMux— since Go 1.22 it matches"GET /orders/{id}"method+path patterns, params viar.PathValue. Encode/decode JSON withjson.NewEncoder/Decoder. - Middleware = a function that wraps a handler and returns a handler, so logging, auth, recover and request-id compose as layers. A panic-recovery layer is essential.
- Thread
r.Context()(with a timeout) into every downstream call; set server timeouts (ReadTimeout/WriteTimeout/IdleTimeout) — never the zero-value server. The server gives each request its own goroutine, so write simple blocking code (and make shared state safe, Lesson 7).
Next lesson: making it correct and fast with the built-in toolchain — testing, benchmarks and profiling — plus the error-handling idioms in a real codebase.