Skip to content
beginner Phase 13 · HTTP Services, JSON, and Middleware

Building an HTTP Server

Route requests with ServeMux patterns and start the standard library server.

45m
0 problems
Topic Progress 0%

The Two Bugs Hiding in a Two-Line Server

A teammate needs a health check endpoint before Friday's demo. Their proposal:

func main() {
	http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

It answers curl localhost:8080/healthz, so it looks done. Two problems hide in those four lines.

No timeouts. With the default server, ReadHeaderTimeout, ReadTimeout, and WriteTimeout are all zero — unbounded. The server reads headers at whatever pace the client chooses. An attacker opens 50,000 connections and dribbles one byte a minute; each pins a goroutine and a buffer until memory runs out. That is the slowloris attack, and the default server loses it. Timeouts are not tuning — they are the admission policy for your listener.

Treating shutdown as a crash. log.Fatal fires on any error from ListenAndServe. Later you will add graceful shutdown, which makes the server return http.ErrServerClosed on purpose — and log.Fatal will turn your drain into a crash that kills in-flight requests. Check the error conditionally from day one.

package main

import (
	"errors"
	"log"
	"net/http"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatal(err)
	}
}

Notice http.NewServeMux() instead of the default mux. http.DefaultServeMux is package-level shared state: any package you import can call http.Handle and silently add a route to your server. An explicit mux is a value you own, pass around, and inspect in tests.

The pattern GET /healthz carries a method prefix — Go 1.22+ routing built into the standard library. A POST /healthz never reaches your handler; the mux answers 405 Method Not Allowed with an Allow header for free.

Underneath sits one interface: http.Handler, a single ServeHTTP(ResponseWriter, *Request) method (Interfaces and Method Sets). The server holds a handler, the mux holds handlers, and handlers can hold other handlers. Every layer of a Go HTTP service repeats that abstraction, which is what makes Middleware possible. Your listener is now honest about failure and bounded in time. The next question is what to do with everything a request can carry.

Routing Without Reaching for a Framework

The API spec says GET /users/{id}, POST /users, GET /users/{id}/orders, and a catch-all for the SPA at /. Before Go 1.22 you stripped prefixes and split on slashes yourself, and every hand-rolled dispatcher grew its own edge cases. The standard mux now parses patterns:

func routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", getUser)
	mux.HandleFunc("POST /users", createUser)
	mux.HandleFunc("GET /users/{id}/orders", listOrders)
	mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFS)))
	mux.HandleFunc("GET /{$}", index) // matches exactly "/", nothing else
	return mux
}

func getUser(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	fmt.Fprintf(w, "user %s", id)
}

Four rules decide which handler runs:

  1. Method mismatches are not misses. If a pattern matches the path but not the verb, the mux returns 405 with Allow: GET instead of 404. Only when no pattern matches the path at all do you get 404 — or the subtree fallback.
  2. Longest match wins. /users/{id}/orders beats {id} beats /. A pattern ending in / matches the whole subtree; {$} matches only the root.
  3. Conflicts panic at registration. Two patterns that overlap ambiguously blow up when routes() runs — at startup, not on the first unlucky request. Fail-fast routing is one reason to register everything in one function.
  4. Query strings never participate. ?page=2 is invisible to matching; you read it with r.URL.Query() in the handler (Reading Requests in Handlers).

Trailing slashes deserve a note: registering /users/ and requesting /users gets an automatic 301 to /users/ (or the reverse), so clients with strict redirect handling need to know. Absolute paths in patterns are required — a pattern without a leading slash panics.

When does a third-party router earn its place? When you need per-subtree middleware mounting, route groups with shared headers, or path patterns the mux cannot express. Frameworks like chi wrap the same http.Handler contract, so choosing one later does not rewrite your handlers. The default answer for most services: start with the standard mux, keep routes() as the single place that knows the URL space, and revisit only when a concrete pattern is missing — not because a framework was mentioned in a blog post.

Handlers That Carry Their Own Dependencies

Your getUser handler needs the database and a logger. The tempting move is package-level variables:

var db *sql.DB // set in main
var logger = slog.Default()

Now every test must mutate global state, two tests running in parallel stomp each other, and the handler's real inputs are invisible in its signature. Go's answer is to make the handler a method on a struct that holds its dependencies:

type Queries interface {
	UserByID(ctx context.Context, id int64) (User, error)
}

type Server struct {
	q   Queries
	log *slog.Logger
}

func NewServer(q Queries, log *slog.Logger) *Server {
	return &Server{q: q, log: log}
}

func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		http.Error(w, "bad id", http.StatusBadRequest)
		return
	}
	u, err := s.q.UserByID(r.Context(), id)
	if err != nil {
		s.log.ErrorContext(r.Context(), "load user", "id", id, "err", err)
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	fmt.Fprintf(w, "%s <%s>", u.Name, u.Email)
}

func (s *Server) routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /users/{id}", s.handleGetUser)
	return mux
}

Read the signature as documentation: this handler depends on a Queries and a logger, nothing else. The interface is declared by the consumer — the server package — not by the database package (Interfaces at the Consumer Side), so tests pass a fake Queries and exercise the handler with no SQL, no network, and no global cleanup (Testing Handlers with httptest).

A method value like s.handleGetUser is already an http.HandlerFunc, so mux.HandleFunc accepts it directly. When you need the struct itself mounted — for example to wrap all its routes — the struct can implement ServeHTTP and satisfy http.Handler outright.

Keep two habits while this shape spreads. First, r.Context() flows into every dependency call, so a client that disconnects cancels the database query too (Context and Cancellation). Second, construct the graph once in main: srv := NewServer(db, logger) — wiring is explicit, and a missing dependency is a compile error rather than a nil dereference at request 9,000.

Draining Connections Before the Pod Disappears

Your service runs behind a load balancer that removes the instance on SIGTERM and then sends remaining traffic elsewhere. If the process exits the moment the signal arrives, every request still being handled dies mid-flight: half-written JSON responses, orphaned transactions. Graceful shutdown gives in-flight work a bounded chance to finish.

func run() error {
	srv := &http.Server{
		Addr:              ":8080",
		Handler:           routes(),
		ReadHeaderTimeout: 5 * time.Second,
	}

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	errCh := make(chan error, 1)
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
		close(errCh)
	}()

	select {
	case err := <-errCh:
		return err // listener failed on its own
	case <-ctx.Done(): // signal arrived
	}
	stop() // restore default signal behavior for a second Ctrl-C

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		return fmt.Errorf("graceful shutdown failed: %w", err)
	}
	return errors.Join(<-errCh...)
}

Three mechanics make this work. Shutdown closes listeners immediately, so the balancer's health check fails and new traffic stops; it then waits for active requests to return. If the deadline passes, it returns context.DeadlineExceeded and you may call srv.Close() to sever stragglers — a decision, not an accident. And signal.NotifyContext cancels a context on the first signal while leaving the second one at its default disposition, so an impatient operator can still force-kill.

Budget the timeout against your own work, not habit: 20 seconds is generous for JSON APIs but short if a job endpoint runs for minutes. Long-running handlers should watch their own timeouts and cancellation inside r.Context(), which Shutdown cancels once the deadline hits.

Deploy-time detail: the ordering matters. Platforms usually flip the balancer first, then signal, then hard-kill after a grace period. Your shutdown timeout must fit inside that grace period or the platform's SIGKILL undoes the politeness. Full treatment of signals and drain strategies lives in Graceful Shutdown; here the contract is narrow — stop accepting, finish what you accepted, exit non-zero only when something actually went wrong.

Quiz

1. A mux registers only `GET /users/{id}`. A client sends `DELETE /users/42`. What does the server respond?

Question 1 options

2. The health check endpoint works locally, but during a security review someone points out the server accepts connections and then waits forever for request headers. Which fix addresses the finding?

Question 2 options

3. After adding srv.Shutdown(ctx), the team's `log.Fatal(err)` on ListenAndServe fires during every deployment and crashes mid-drain. What happened?

Question 3 options

4. Routes register `GET /users/{id}` and `GET /users/{id}/orders`. A request arrives for `GET /users/7/orders`. Which handler runs?

Question 4 options

Flashcards

Question

Why construct your own ServeMux instead of registering routes with http.HandleFunc?

Answer

http.HandleFunc registers on the package-global DefaultServeMux, so any imported package can add or collide with your routes and tests cannot isolate routing. An explicit mux is a value you create, pass to http.Server, and exercise independently.

Question

Which three timeouts should a production http.Server set, and what does each one bound?

Answer

ReadHeaderTimeout bounds time to finish request headers (stops slowloris), ReadTimeout bounds reading the whole request, and WriteTimeout bounds writing the response so a slow reader cannot pin a handler forever. All three default to zero — unlimited — if you never set them.

Question

What does http.Server.Shutdown do, and what happens when its context deadline expires?

Answer

It stops accepting new connections, closes idle ones, and waits for active handlers to return. If the context expires first, Shutdown aborts the wait and returns an error — in-flight requests are still running, so you either extend the budget or call Close to sever them.

Question

How does the Go 1.22 ServeMux answer a path that exists but with an unregistered method?

Answer

With 405 Method Not Allowed plus an Allow header listing the methods some pattern registered for that path — the mux handles this itself, before any handler runs. A path no pattern claims gets 404 (or the subtree fallback if one exists).

Question

Why put database and logger dependencies on a Server struct instead of package-level variables?

Answer

The struct makes dependencies part of the handler's visible contract, lets main() wire them once, and lets tests construct a Server with fakes without mutating globals — which is what keeps parallel tests from interfering with each other.

Revision Notes

Key Takeaways

  • 1. The default server has zero timeouts; ReadHeaderTimeout is the minimum defense against connection-holding attacks
  • 2. ServeMux patterns give method-aware routing, automatic 405s, and startup panics for ambiguous registrations — no framework required
  • 3. An explicit ServeMux and struct-based handlers keep routing and dependencies inspectable instead of hidden in package globals
  • 4. Graceful shutdown means: stop accepting, finish in-flight work within a deadline, and never treat http.ErrServerClosed as fatal

Interview Tips

  • • "How do you route in Go without a framework?" — walk pattern strings, `r.PathValue`, longest-match precedence, and the automatic 405, then say when you would adopt chi
  • • "What timeouts does an http.Server need?" — name ReadHeaderTimeout as the slowloris fix and explain that all three default to unlimited
  • • "Describe graceful shutdown" — signal context, Shutdown drains within a deadline, and the error check that ignores http.ErrServerClosed
  • • "How would you make a handler testable?" — dependencies on a struct, consumer-declared interface for the data layer, and constructing the graph once in main

Cheat Sheet

Building an HTTP Server

Pattern syntax (Go 1.22+ ServeMux):

Pattern Matches Notes
GET /users/{id} method + path, captures segment read with r.PathValue("id")
POST /users exact path + method other verbs -> 405 + Allow
/static/ subtree trailing slash = prefix
/{$} only / "/" would match everything

Server checklist: explicit http.NewServeMux(); ReadHeaderTimeout set (slowloris); ListenAndServe error checked against http.ErrServerClosed; signal-triggered Shutdown with a deadline shorter than the platform grace period.

Code template:

srv := &http.Server{
	Addr:              ":8080",
	Handler:           routes(),
	ReadHeaderTimeout: 5 * time.Second,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() { /* ListenAndServe; ignore ErrServerClosed */ }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)

Decision rules: dependencies -> struct fields, never globals; overlapping patterns -> longest match wins; exit codes -> non-zero only for real failures.

Continue Learning