arrow_backBACK TO GO FOR BACKEND ENGINEERS — CONCURRENCY, SIMPLICITY AND REAL NUMBERS
Lesson 08Go for Backend Engineers — Concurrency, Simplicity and Real Numbers5 min read

Building an HTTP Service

October 29, 2026

TL;DR

In Go the standard library IS the web framework — net/http is enough for most services, no third-party router needed. A HANDLER is just func(w http.ResponseWriter, r *http.Request). Route with http.ServeMux; since Go 1.22 it matches method + path patterns: mux.HandleFunc("GET /orders/{id}", h), read params with r.PathValue("id"). MIDDLEWARE is a function that wraps a handler and returns a handler, so logging, auth, panic-recovery and request-id all compose as layers around your logic — same shape, stacked. Encode replies with json.NewEncoder(w).Encode(v); decode requests with json.NewDecoder(r.Body).Decode(&v). Read cancellation/deadline from r.Context() (Lesson 6) and thread it into every DB/HTTP call. Critical for production: never run the zero-value server — set ReadTimeout, WriteTimeout, IdleTimeout, or one slow client ties up a connection forever. The server gives each request its OWN goroutine automatically (Lesson 4), so your handler can block on I/O and everyone else is still served — write simple blocking code.

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

An HTTP service with net/http: the standard library is the framework — router, middleware, handlers. A request, GET /orders/7, flows through a chain of middleware layers — logging, then auth, then recover — into the handler, which holds your logic and writes JSON. What you build on, and what each piece does: http.ServeMux (Go 1.22 plus) routes by method and path pattern, mux.HandleFunc GET /orders/{id} h; for most services you do not need a third-party router at all. A handler is just func(w http.ResponseWriter, r pointer http.Request); read r.Context() for cancellation, r.PathValue id for params; encode the reply with json.NewEncoder(w).Encode(v). Middleware is a function that wraps a handler and returns a handler, so logging, auth, panic-recovery and request-id all compose as layers around your logic, same shape stacked. http.Server with ReadTimeout, WriteTimeout, IdleTimeout set — never run the zero-value server in prod, it has no timeouts and one slow client can tie up a connection forever. One goroutine per request automatically, so your handler can block on I/O and the server still serves everyone else; write simple blocking code, the runtime handles the concurrency.

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

  1. 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 set ReadTimeout, WriteTimeout, IdleTimeout.
  2. Ignoring r.Context(). Not passing the request context into DB and API calls means a disconnected client’s work keeps running. Thread r.Context() (wrapped with a timeout) into every downstream call.
  3. 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).
  4. 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 with http.MaxBytesReader to cap request size.
  5. Writing the status code after the body. Call w.WriteHeader(code) (or http.Error) before writing the body; once you write bytes, the status is locked to 200. Set headers, then status, then body — and return after an error response so you don’t also write a success body.
  6. 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/http serves production traffic with no third-party router for most services.
  • A handler is just func(w http.ResponseWriter, r *http.Request). Route with http.ServeMux — since Go 1.22 it matches "GET /orders/{id}" method+path patterns, params via r.PathValue. Encode/decode JSON with json.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.