open atlas
↑ Back to track
Go, zero to senior GO · 03 · 01

net/http as the framework: Handler, the 1.22 ServeMux, and routing without dependencies

http.Handler is one method and HandlerFunc adapts plain functions. Since Go 1.22 ServeMux routes by method and {id} wildcards with most-specific-wins precedence and panics on conflicts at registration. For most services the stdlib mux is the framework.

GO Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior
Already know this unit? Take a 1-minute quick check →

The dependency audit flagged it on a Friday: the router library holding up a payments API had been archived on GitHub — no maintainers, no CVE response, 47 transitive packages. The team budgeted two sprints for a migration to another framework. Then someone actually diffed what the library did for them: path parameters, method matching, a NotFound hook. That was it. The whole surface had been in the standard library since Go 1.22. The migration took one afternoon — r.PathValue("id") instead of the framework’s params map, GET /users/{id} instead of chained builder calls — and deleted 47 dependencies from go.sum. The only real bug they hit was instructive: two old routes that had silently shadowed each other in the framework’s first-match-wins order now made ServeMux panic at startup with a pattern conflict. The panic was the stdlib telling them about a routing ambiguity that had been shipping for two years.

One interface, one method

Before you reach for a router library, understand what the stdlib already gives you — the answer changes every dependency decision you make afterward.

Everything in Go’s HTTP stack — routers, middleware, frameworks, your code — speaks one contract:

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

// HandlerFunc adapts a plain function to the interface —
// it's a named function type with a ServeHTTP method that calls itself.
type HandlerFunc func(http.ResponseWriter, *http.Request)

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

HandlerFunc is the trick worth pausing on: it is a function type that implements the interface, so any function with the right signature converts into a Handler with zero allocation — mux.Handle("/x", http.HandlerFunc(myFunc)), or the shorthand mux.HandleFunc("/x", myFunc). And ServeMux itself implements Handler, which means routers nest: a mux can be a handler inside another mux, a middleware wraps a mux the same way it wraps a single endpoint. This uniformity is why Go’s ecosystem composes so cleanly — there is no framework-specific handler signature to bridge, just one method. The server side mirrors it: http.Server takes any Handler as its root; http.ListenAndServe(addr, mux) spawns one goroutine per connection, so your handler code is concurrent by default and must be safe for it — a plain map shared across handlers without a mutex is a data race on the second simultaneous request.

The 1.22 ServeMux: methods, wildcards, precedence

Until Go 1.22 the stdlib mux matched only literal prefixes — no methods, no path params — and that gap is what third-party routers filled for a decade. The 1.22 patterns close it:

mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", getUser)        // {id} matches one segment
mux.HandleFunc("POST /users", createUser)          // method-specific
mux.HandleFunc("GET /files/{path...}", serveFile)  // {path...} matches the rest
mux.HandleFunc("GET /{$}", home)                   // {$} = exactly "/", no subtree

func getUser(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id") // wildcard value, no context dance
	// ...
}

The rules that matter in production:

  • Method patterns: a pattern with a method only matches that method — except GET, which also matches HEAD (the server discards the body). A method-less pattern matches all methods. A request that matches a path but no method gets 405 Method Not Allowed automatically.
  • Precedence is most-specific-wins, not first-registered-wins: GET /users/new beats GET /users/{id}, and /users/{id} beats /users/. Registration order is irrelevant — a deliberate design choice so a route’s behavior never silently depends on init order.
  • Conflicts panic at registration: if two patterns overlap and neither is more specific (GET /a/{x}/c vs GET /a/b/{y} — both match /a/b/c), Handle panics at startup. Loud and early beats quiet and wrong.
  • A trailing-slash pattern like /static/ matches the whole subtree and triggers a 301 redirect from /static to /static/.
Quiz

A service registers GET /users/{id} first and GET /users/new second. A request comes in for GET /users/new. Which handler runs, and why?

Why this works

Why panic on conflict instead of just picking an order? Because first-match-wins makes route resolution depend on code layout: move a registration between files, or change init order, and an endpoint silently swaps handlers — the exact two-years-latent bug from the Hook. Most-specific-wins is order-independent: the same set of patterns always resolves identically, and the only ambiguous cases left are true ties, which fail fast at startup where a deploy pipeline catches them, not at 3 a.m. on a request.

When you actually need a framework

When you find yourself reaching for a router library, ask what you actually need that the stdlib mux does not provide — the list is shorter than you expect.

Be honest about what third-party web frameworks add on top of this: parameter binding and validation, content negotiation helpers, middleware ecosystems, route groups. None of that is routing — the 1.22 mux matches per-request in well under a microsecond, which disappears next to a single 200 µs database round trip. The costs of a framework are real and recurring: a non-standard handler signature that every middleware must adapt to, a dependency tree you audit forever, and an abstraction between you and ResponseWriter exactly when you need Flusher or Hijacker. The pragmatic line most Go teams draw: start with net/http plus a thin middleware chain; reach for a router library only when you need something the mux genuinely lacks (regex constraints, per-route middleware DSLs). What you should not do is hand-roll validation badly and call it framework-free — pull a focused library for the focused job instead of a framework for everything.

Quiz

Why does http.HandlerFunc(myFunc) satisfy the http.Handler interface even though myFunc is just a plain function?

Recall before you leave
  1. 01
    Explain how HandlerFunc turns a plain function into an http.Handler, and why ServeMux nesting falls out of the same design.
  2. 02
    Describe the 1.22 ServeMux precedence and conflict rules, and the failure mode they were designed to kill.
Recap

Go’s HTTP stack is one interface deep: Handler’s single ServeHTTP method is the contract for endpoints, routers, and middleware alike, and HandlerFunc adapts any plain function to it for free because it is a function type carrying its own ServeHTTP. The server spawns a goroutine per connection, so handler code is concurrent from the first deploy and shared state needs synchronization from day one. Since Go 1.22 the stdlib ServeMux covers what third-party routers existed for: method-qualified patterns where GET also serves HEAD, single-segment {id} wildcards read back with r.PathValue, {path...} for trailing rest, {$} for exact root, and automatic 405 when the path matches but the method does not. Precedence is most-specific-wins and deliberately ignores registration order — GET /users/new beats GET /users/{id} wherever it is declared — and patterns that overlap with no winner panic at registration, converting silent route shadowing into a startup failure your pipeline catches. Pattern matching costs well under a microsecond, noise next to any real handler work. Frameworks still earn their place for binding, validation, and middleware ecosystems, but that is ergonomics, not routing — and the price is a custom handler signature, a permanent dependency audit, and indirection in front of ResponseWriter exactly where Flusher and Hijacker live. Now when you evaluate a new router dependency, check first: does the mux lack something concrete? Default to the stdlib; add focused libraries for focused jobs.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.