Go’s standard library ships a production HTTP server. Learn handlers, routing with ServeMux method and path patterns, the rules of ResponseWriter, what happens to each request, and why http.Server needs timeouts.
Go doesn’t need a web framework to serve HTTP. The net/http package in the standard library has a real server, a router and a client, and large production services run on it directly.
This post covers handlers, routing with http.ServeMux, the rules for writing a response, what happens to one request from start to finish, and why you should build an http.Server yourself instead of calling http.ListenAndServe. Every program below was run on Go 1.26, and its output is pasted from the run.
A handler is one method
A handler in Go is any value with a ServeHTTP method. The net/http package defines it as an interface:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
That’s the same idea as the part on interfaces: a small interface, satisfied without saying so. The server calls ServeHTTP once for every request. The *http.Request holds what the client sent. The http.ResponseWriter is where you write the reply.
Here is a handler made from a struct. To run a real server inside a verified example, we use httptest.NewServer. It starts your handler on a random free port on your own machine, gives you its address in srv.URL, and shuts it down when you call Close. The client side is http.Get, which sends a real request over a real connection.
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
type greeter struct {
greeting string
}
func (g greeter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s, you asked for %s\n", g.greeting, r.URL.Path)
}
func main() {
var h http.Handler = greeter{greeting: "Hello"}
srv := httptest.NewServer(h)
defer srv.Close()
res, err := http.Get(srv.URL + "/tasks")
if err != nil {
fmt.Println("error:", err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res.Status)
fmt.Println(res.Header.Get("Content-Type"))
fmt.Print(string(body))
}
It prints:
200 OK
text/plain; charset=utf-8
Hello, you asked for /tasks
greeter never says it implements http.Handler. It has the method, so the assignment compiles. fmt.Fprintf works on w because a ResponseWriter is also an io.Writer, the interface you met with io.Reader and io.Writer.
The handler never set a status or a Content-Type, and the client still got both. When you don’t choose, the server sends 200 and guesses the content type from the first bytes you write. You’ll see below exactly when that happens.
http.HandlerFunc turns a function into a handler
Most handlers don’t need a struct, so net/http has an adapter. http.HandlerFunc is a function type with a ServeHTTP method that simply calls the function:
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }
Converting an ordinary function to that type gives it the method, so it becomes an http.Handler. This program also uses the second tool from httptest. httptest.NewRecorder is a ResponseWriter that stores what the handler wrote, so you can call a handler directly with no network at all:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func health(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
}
func main() {
h := http.HandlerFunc(health)
fmt.Printf("%T\n", h)
req := httptest.NewRequest("GET", "/health", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
fmt.Println(rec.Code)
fmt.Print(rec.Body.String())
}
It prints:
http.HandlerFunc
200
ok
http.HandlerFunc(health) is a type conversion, not a call. Nothing runs until ServeHTTP is called. httptest.NewServer tests the whole trip through a connection. httptest.NewRecorder tests just the handler, which is faster. The part on testing and shipping the API goes much deeper into both.
Routing with http.ServeMux
A real server has more than one handler, so something has to choose which one gets each request. In net/http that’s http.ServeMux, a router that is itself a handler. You register patterns on it, and its own ServeHTTP picks the right handler and calls it.
Since Go 1.22, a pattern can name an HTTP method as well as a path, and a path can hold wildcards in braces. Inside the handler, r.PathValue returns what a wildcard matched:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "task %s\n", r.PathValue("id"))
})
mux.HandleFunc("DELETE /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "deleted task %s\n", r.PathValue("id"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
send := func(method, path string) {
req, _ := http.NewRequest(method, srv.URL+path, nil)
res, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Println("error:", err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Printf("%-6s %-12s %d %q", method, path, res.StatusCode, body)
if allow := res.Header.Get("Allow"); allow != "" {
fmt.Printf(" Allow: %s", allow)
}
fmt.Println()
}
send("GET", "/tasks/42")
send("DELETE", "/tasks/7")
send("PUT", "/tasks/42")
send("GET", "/tasks")
send("GET", "/tasks/a%2Fb")
send("GET", "/users/1")
}
It prints:
GET /tasks/42 200 "task 42\n"
DELETE /tasks/7 200 "deleted task 7\n"
PUT /tasks/42 405 "Method Not Allowed\n" Allow: DELETE, GET, HEAD
GET /tasks 404 "404 page not found\n"
GET /tasks/a%2Fb 200 "task a/b\n"
GET /users/1 404 "404 page not found\n"
Read the lines one at a time:
GET /tasks/42andDELETE /tasks/7each reach their own handler, and{id}captured the number.PUT /tasks/42found a path that exists, but no pattern allowsPUT. ServeMux answered 405 Method Not Allowed on its own, with anAllowheader listing the methods that would work.HEADis in the list because aGETpattern also matchesHEADrequests.GET /tasksis a 404, not a 405.{id}has to match a path segment, and there isn’t one, so no pattern matches the path at all./tasks/a%2Fbsurprised us.%2Fis an escaped slash, so the wildcard sees one segment. ButPathValuereturns it unescaped, asa/b. Treat a path value like any other input from a client, and check it before you use it.- A path no pattern knows gets 404 with the body
404 page not found.
The method in a pattern goes before the path with one space, and a pattern with no method matches every method. Before Go 1.22 none of this existed. People checked r.Method by hand or reached for a third-party router. For most APIs you don’t need one now.
Explain it like I’m ten
Picture the sorting room of a post office. A wall of pigeonholes has an address written above each one: “Main Street 12”, “Main Street, any house”, “Anywhere in town”. A letter comes in, and the sorter reads its address and drops it into a pigeonhole. A carrier takes everything in that hole and delivers it.
If an address fits more than one hole, the sorter picks the most exact one. A letter for Main Street 12 goes in the “Main Street 12” hole, even though “Main Street, any house” would also take it. If no hole fits, the letter goes back with “address unknown” stamped on it.
The precise version
ServeMux holds a set of patterns, each paired with a handler. For every request it finds all the patterns that match the method and the path. If several match, the most specific one wins. One pattern is more specific than another if it matches a strict subset of the requests the other matches. The order you registered them in doesn’t matter.
If nothing matches the path, ServeMux calls its built-in not-found handler, which writes 404. If the path matches but the method doesn’t, it writes 405 with an Allow header.
Where the analogy breaks: a post office sorts by address only. ServeMux also reads the method, which is like sorting by address and by whether the envelope says “deliver”, “collect” or “cancel”. A wildcard also does more than a pigeonhole: it copies part of the address, such as 42, and hands it to the carrier. And a post office muddles through when two holes are equally exact. ServeMux refuses to start, as the next section shows.
Wildcards: {name...} and {$}
A plain wildcard such as {id} matches exactly one path segment. Two special forms cover the other cases. {path...} at the end of a pattern matches all the remaining segments, slashes included. {$} matches only the end of the path, which is how you register the root without catching everything:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "home page")
})
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "file %q\n", r.PathValue("path"))
})
for _, path := range []string{"/", "/about", "/files/notes/2026/todo.txt", "/files/"} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", path, nil))
fmt.Printf("%-27s %d %s", path, rec.Code, rec.Body.String())
}
}
It prints:
/ 200 home page
/about 404 404 page not found
/files/notes/2026/todo.txt 200 file "notes/2026/todo.txt"
/files/ 200 file ""
Without {$}, the pattern GET / would end in a slash, and a pattern ending in a slash matches every path under it. /about would get the home page instead of a 404. The {path...} wildcard can also match nothing at all, which is why /files/ reached the handler with an empty string.
The most specific pattern wins
When a request matches several patterns, ServeMux doesn’t take the first one registered. It takes the one that matches the fewest possible requests. This program registers four overlapping patterns, deliberately in the “wrong” order:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func reply(text string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, text)
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/tasks/{id}", reply(`"/tasks/{id}"`))
mux.HandleFunc("GET /tasks/{id}", reply(`"GET /tasks/{id}"`))
mux.HandleFunc("GET /tasks/new", reply(`"GET /tasks/new"`))
mux.HandleFunc("/", reply(`"/"`))
for _, t := range []struct{ method, path string }{
{"GET", "/tasks/new"},
{"GET", "/tasks/42"},
{"PUT", "/tasks/42"},
{"GET", "/tasks/42/notes"},
} {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(t.method, t.path, nil))
fmt.Printf("%-4s %-16s -> %s", t.method, t.path, rec.Body.String())
}
}
It prints:
GET /tasks/new -> "GET /tasks/new"
GET /tasks/42 -> "GET /tasks/{id}"
PUT /tasks/42 -> "/tasks/{id}"
GET /tasks/42/notes -> "/"
A literal segment, new, beats a wildcard, {id}. A pattern with a method beats the same path without one, so GET goes to the method-specific handler and PUT falls back to the one that takes any method. / ends in a slash, so it matches every path, and /tasks/42/notes lands there because nothing else fits. That’s also why there’s no 405 in this program: the catch-all always matches.
Sometimes neither of two patterns is more specific. GET /{kind}/42 and /tasks/{id} both match /tasks/42, but each also matches paths the other doesn’t. Registering both makes HandleFunc panic at startup. The message names both patterns and the line each was registered on, then explains:
GET /{kind}/42 and /tasks/{id} both match some paths, like "/tasks/42".
But neither is more specific than the other.
A panic at startup is the helpful kind. You find the ambiguity the first time you run the server, not when an unlucky request arrives.
Writing a response: header, status, body
An HTTP response goes out in a fixed order: the status line, then the headers, then the body. ResponseWriter makes you follow that order, because once a part is on its way, it can’t be changed. Three calls map onto the three parts:
w.Header()returns the header map. Change it first.w.WriteHeader(code)sends the status line and the headers.w.Writesends body bytes. IfWriteHeaderhasn’t been called yet, the firstWritecallsWriteHeader(200)for you.
Here’s a handler that does it in the right order:
package main
import (
"fmt"
"net/http"
"net/http/httptest"
)
func create(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Location", "/tasks/43")
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "created task 43")
}
func main() {
rec := httptest.NewRecorder()
create(rec, httptest.NewRequest("POST", "/tasks", nil))
res := rec.Result()
fmt.Println(res.Status)
fmt.Println("Location:", res.Header.Get("Location"))
fmt.Print(rec.Body.String())
}
It prints:
201 Created
Location: /tasks/43
created task 43
Use rec.Result() to read what a client would see. rec.Header() is the handler’s live map, and it still shows headers that were set too late to be sent.
Now the wrong order. This handler writes the body first, then tries to set a header and a 404. To see the server’s complaint, the program builds the test server with httptest.NewUnstartedServer, points its ErrorLog at stdout with no timestamp, and only then starts it:
package main
import (
"fmt"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
)
func lateHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "task 42")
w.Header().Set("X-Task-Id", "42")
w.WriteHeader(http.StatusNotFound)
}
func main() {
srv := httptest.NewUnstartedServer(http.HandlerFunc(lateHandler))
// Send the server's error log to stdout, with no timestamp, so we can see it.
srv.Config.ErrorLog = log.New(os.Stdout, "server log: ", 0)
srv.Start()
defer srv.Close()
res, err := http.Get(srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println("status:", res.StatusCode)
fmt.Printf("X-Task-Id: %q\n", res.Header.Get("X-Task-Id"))
fmt.Print("body: ", string(body))
}
It prints:
server log: http: superfluous response.WriteHeader call from main.lateHandler (main.go:15)
status: 200
X-Task-Id: ""
body: task 42
The client got a 200, not a 404, and no X-Task-Id header. The first Fprintln fixed the status at 200 and froze the headers. The late Header().Set changed a map nobody reads any more, and did it silently. The late WriteHeader did nothing, except leave a “superfluous” line in the server log with the function and line number. If you see that line in a real log, look for an error path that writes after the body has started.
The life of one request
A request passes through several hands between the client and your handler, and each one does a single job. Watch one request go through:
One request from start to finish. The server gives the connection its own goroutine, ServeMux picks the most specific matching pattern, and the handler reads the path value, then writes the header, the status and the body in that order before the response goes back.
Here are those steps in words, in case the animation doesn’t play for you:
- A client sends
GET /tasks/42. - The server accepts the connection and starts a new goroutine to handle it.
- ServeMux compares the request with its patterns,
GET /tasks,GET /tasks/{id}andPOST /tasks, and picksGET /tasks/{id}. - That pattern’s handler runs and calls
r.PathValue("id"), which returns"42". - The handler sets a header, calls
WriteHeader(200), and writes the body. - The response travels back to the client, and the goroutine has nothing left to do.
Step 2 matters more than it looks. The server runs Accept in a loop, and for each new connection it starts a goroutine, as in the part on goroutines. Requests that arrive on that connection take turns in its goroutine, and over HTTP/2 each request gets a goroutine of its own. Either way, a hundred clients means a hundred goroutines running your handlers at the same time, and you never wrote go.
This program proves two handlers really do run at once. /wait blocks until a channel is closed, and only /release closes it:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
)
func get(url string) string {
res, err := http.Get(url)
if err != nil {
return "error: " + err.Error()
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
return string(body)
}
func main() {
release := make(chan struct{})
mux := http.NewServeMux()
mux.HandleFunc("GET /wait", func(w http.ResponseWriter, r *http.Request) {
<-release // blocks until another request closes the channel
fmt.Fprint(w, "wait: released")
})
mux.HandleFunc("GET /release", func(w http.ResponseWriter, r *http.Request) {
close(release)
fmt.Fprint(w, "release: done")
})
srv := httptest.NewServer(mux)
defer srv.Close()
waitResult := make(chan string)
go func() { waitResult <- get(srv.URL + "/wait") }()
fmt.Println(get(srv.URL + "/release"))
fmt.Println(<-waitResult)
}
It prints:
release: done
wait: released
If the server handled one request at a time, /wait would hold the only worker forever, /release would never run, and the program would hang. It finishes, so the two handlers were running at the same time.
Shared state in a handler needs a lock
Handlers that run at the same time and touch the same variable have a data race, exactly like the goroutines in the part on sync. The race is easy to miss here, because nothing in your code starts a goroutine. This handler counts visits with no lock, and 50 requests arrive together:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
)
type visits struct {
count int
}
func (v *visits) ServeHTTP(w http.ResponseWriter, r *http.Request) {
v.count++ // no lock: every request runs in its own goroutine
fmt.Fprint(w, v.count)
}
func main() {
srv := httptest.NewServer(&visits{})
defer srv.Close()
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
res, err := http.Get(srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
})
}
wg.Wait()
fmt.Println("done")
}
Run with go run -race ., it prints a report like this (the ... lines stand for addresses, file paths and goroutine numbers that change every run):
WARNING: DATA RACE
...
main.(*visits).ServeHTTP()
...
net/http.(*conn).serve()
...
done
exit status 66
Look at the stack in the report. Below your ServeHTTP is net/http.(*conn).serve(), the goroutine the server started for each connection. The race detector found two of them writing count with nothing to keep them apart.
The fix is the one from the part on sync: a mutex in the struct, and a pointer receiver so every request shares the same one:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
)
type visits struct {
mu sync.Mutex
count int
}
func (v *visits) ServeHTTP(w http.ResponseWriter, r *http.Request) {
v.mu.Lock()
v.count++
n := v.count
v.mu.Unlock()
fmt.Fprint(w, n)
}
func main() {
v := &visits{}
srv := httptest.NewServer(v)
defer srv.Close()
var wg sync.WaitGroup
for range 50 {
wg.Go(func() {
res, err := http.Get(srv.URL)
if err != nil {
fmt.Println("error:", err)
return
}
io.Copy(io.Discard, res.Body)
res.Body.Close()
})
}
wg.Wait()
v.mu.Lock()
fmt.Println("visits:", v.count)
v.mu.Unlock()
}
It prints:
visits: 50
The handler copies the count into n while it holds the lock, then unlocks before writing the response. Writing to a slow client can take a long time, and nobody else should wait for that. Anything a handler reads or writes outside its own request, such as a map of tasks, a cache or a counter, needs the same care.
r.Context() ends when the client goes away
Every request carries a context, and the server cancels it when the client disconnects. That’s the context from the part on concurrency patterns, wired in for you. A handler doing slow work should watch r.Context().Done() and stop, because nobody is waiting for the answer any more.
In this program the client gives up as soon as the handler has started. The handler’s job would take 10 seconds, a wide margin, so the outcome is always the same:
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"time"
)
func main() {
started := make(chan struct{})
outcome := make(chan string)
slow := func(w http.ResponseWriter, r *http.Request) {
close(started)
select {
case <-time.After(10 * time.Second):
fmt.Fprintln(w, "report ready")
outcome <- "handler: finished the work"
case <-r.Context().Done():
outcome <- "handler: stopped early: " + r.Context().Err().Error()
}
}
srv := httptest.NewServer(http.HandlerFunc(slow))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
go func() {
<-started
cancel() // the client gives up once the handler is running
}()
req, _ := http.NewRequestWithContext(ctx, "GET", srv.URL, nil)
_, err := http.DefaultClient.Do(req)
fmt.Println("client: canceled:", errors.Is(err, context.Canceled))
fmt.Println(<-outcome)
}
It prints:
client: canceled: true
handler: stopped early: context canceled
Cancelling the client’s context closed the connection. The server noticed, cancelled r.Context(), and the handler’s select took the Done case at once instead of waiting 10 seconds. In a real handler you pass r.Context() to the database query or the outgoing call, and those stop too. The context also ends when ServeHTTP returns, so don’t keep it for work that should outlive the request.
Build an http.Server, don’t call http.ListenAndServe
Every tutorial’s first Go server is http.ListenAndServe(":8080", mux). It works, but it builds an http.Server with every field at its zero value, and for the timeouts, zero means “wait forever”.
That’s a real problem on the internet. A client can open a connection and send its request headers one byte every few seconds. With no ReadHeaderTimeout, the server waits patiently, holding a goroutine and an open connection. Enough of those clients, and the server runs out of open connections it can hold, without ever seeing a full request. The attack is old enough to have a name, Slowloris, and it costs the attacker almost nothing.
The fix is to build the server yourself and set the timeouts:
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "task %s\n", r.PathValue("id"))
})
srv := &http.Server{
Addr: "localhost:8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Println("listening on", srv.Addr)
log.Fatal(srv.ListenAndServe())
}
This one isn’t run by our checker, because it listens on a fixed port until you stop it. Run it yourself, and from a second terminal:
curl localhost:8080/tasks/42
That prints task 42. Here’s what each timeout limits:
ReadHeaderTimeout: how long a client has to send the request headers. This is the one that stops Slowloris, and the one to never leave out.ReadTimeout: how long reading the whole request may take, body included.WriteTimeout: how long the server has to write the response after the headers are read.IdleTimeout: how long a kept-alive connection may sit unused between requests.
The numbers above are sensible starting points, not rules. A server that accepts large uploads or streams long responses needs different ones. ListenAndServe also blocks until the server fails, and log.Fatal then exits without letting requests in flight finish. Choosing timeouts for production and shutting down gracefully are both covered in the part on testing and shipping the API.
What to remember
- An
http.Handleris any type withServeHTTP(http.ResponseWriter, *http.Request).http.HandlerFuncturns a plain function into one. - Since Go 1.22, ServeMux patterns take a method and wildcards:
"GET /tasks/{id}", read withr.PathValue("id").{rest...}matches the rest of the path, and{$}matches only the end. - The most specific pattern wins, whatever the registration order. Ambiguous patterns panic at registration. A known path with the wrong method gets 405 and an
Allowheader, and an unknown path gets 404. - Set headers, then call
WriteHeader, then write the body. The firstWritesends 200 and freezes the headers, and anything later is ignored. - Requests run in their own goroutines, so shared state in a handler needs a mutex, and
-racewill find the places that don’t have one. r.Context()is cancelled when the client goes away. Pass it to slow work.- Build an
http.Serverwith at leastReadHeaderTimeoutset.http.ListenAndServewaits forever on slow clients.
Your handler runs on many goroutines at once, whether you started them or not.