This tutorial builds a real HTTP server in Go using only the standard library: the Handler interface, Go 1.22 method routing with PathValue, correct response writing, timeouts, static files, and graceful shutdown. Every example is compile-tested on Go 1.24.7 with real output. After reading you can ship a net/http server without a framework.
Works with Go 1.22+ (the routing patterns shown need 1.22; everything else is older). If you are new to Go, start with the complete Go tutorial and come back.
Since Go 1.22, net/http does method-aware routing and path variables on its own. Most tutorials still show the pre-1.22 world where the mux only matched prefixes and you either parsed r.URL.Path by hand or installed a router on day one. You do not need to anymore. Treated correctly, net/http is enough for a lot of production services.
The Handler interface is the one thing net/http actually calls
Everything in a Go server reduces to one interface. From the net/http docs:
type Handler interface {
ServeHTTP(w http.ResponseWriter, r *http.Request)
}
When a request arrives, the server calls ServeHTTP on one goroutine per connection, hands you a ResponseWriter to build the reply and a *Request to read the incoming data. That is the whole contract. A router is just a Handler that looks at the request and calls another Handler.
Writing a struct with a ServeHTTP method every time would be tedious, so the standard library ships HandlerFunc, an adapter that turns a plain function into a Handler. Both forms are equivalent:
// healthHandler satisfies http.Handler because it has a ServeHTTP method.
type healthHandler struct {
service string
}
func (h healthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "%s is healthy\n", h.service)
}
func main() {
handlers := []struct {
name string
h http.Handler
}{
{"struct handler", healthHandler{service: "orders"}},
{"HandlerFunc", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello from %s\n", r.URL.Path)
})},
}
for _, tc := range handlers {
req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder()
tc.h.ServeHTTP(rec, req)
body, _ := io.ReadAll(rec.Result().Body)
fmt.Printf("[%s] status=%d body=%q\n", tc.name, rec.Code, body)
}
}
Output:
[struct handler] status=200 body="orders is healthy\n"
[HandlerFunc] status=200 body="hello from /status\n"
That test uses httptest: NewRequest fakes an incoming request and NewRecorder captures the response in memory, so you can call ServeHTTP directly with no socket, no port, no network. Every example below is exercised the same way, which is why the outputs are real rather than described.
ServeMux and Go 1.22 routing: method, pattern, and PathValue
A ServeMux maps request patterns to handlers. Before Go 1.22 a pattern was only a path, and you checked r.Method by hand inside every handler. Now a pattern can carry an HTTP method and named wildcards. The routing enhancements blog post has the full grammar; the parts you use daily are METHOD /path and {name} segments read back with r.PathValue.
mux := http.NewServeMux()
// Method + path pattern with a named wildcard. Go 1.22+.
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "fetching user %s\n", id)
})
mux.HandleFunc("POST /users", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
fmt.Fprintln(w, "user created")
})
Exercise it through a real server with httptest.NewServer, which starts the mux on a loopback port and hands back a client:
srv := httptest.NewServer(mux)
defer srv.Close()
for _, c := range []struct{ method, path string }{
{"GET", "/users/42"},
{"POST", "/users"},
{"DELETE", "/users/42"}, // no DELETE registered for this pattern
{"GET", "/nope"}, // no matching pattern at all
} {
req, _ := http.NewRequest(c.method, srv.URL+c.path, nil)
resp, _ := srv.Client().Do(req)
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
fmt.Printf("%-6s %-12s -> %d Allow=%q body=%q\n",
c.method, c.path, resp.StatusCode, resp.Header.Get("Allow"), strings.TrimSpace(string(body)))
}
Output:
GET /users/42 -> 200 Allow="" body="fetching user 42"
POST /users -> 201 Allow="" body="user created"
DELETE /users/42 -> 405 Allow="GET, HEAD" body="Method Not Allowed"
GET /nope -> 404 Allow="" body="404 page not found"
Two behaviors here you would otherwise write yourself. The DELETE returns 405 Method Not Allowed with an Allow: GET, HEAD header, generated by the mux because a GET pattern exists for that path but no DELETE does. (Registering GET also answers HEAD, which is why HEAD shows up in Allow.) The unmatched path returns a plain 404. You did not write a single if r.Method != ... check.
One precedence rule worth knowing: patterns are matched by specificity, not registration order. GET /users/{id} and GET /users/me can coexist, and a request to /users/me takes the literal pattern. If two patterns are equally specific and both match, the mux panics at registration, which surfaces the conflict at startup instead of in production.
Writing responses correctly: headers, then status, then body
A ResponseWriter writes in a fixed order that the type does not enforce but the HTTP protocol does. You set headers, then write the status code, then write the body. Get the order wrong and Go warns you or silently ignores you.
// Correct: set headers, then status, then body.
mux.HandleFunc("GET /correct", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusAccepted) // 202
fmt.Fprintln(w, "accepted")
})
// Wrong: writing the body sends a 200, then WriteHeader is too late.
mux.HandleFunc("GET /toolate", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "already writing the body")
w.WriteHeader(http.StatusInternalServerError) // superfluous, ignored
})
The first Write call implicitly sends a 200 and flushes the header block. Any WriteHeader after that cannot change a status that is already on the wire, so net/http logs http: superfluous response.WriteHeader call to the server’s error log and drops it. Captured from the real server:
/correct -> 202 Content-Type="text/plain; charset=utf-8" body="accepted"
/toolate -> 200 Content-Type="text/plain; charset=utf-8" body="already writing the body"
server log: http: superfluous response.WriteHeader call from main.main.func2 (main.go:28)
The /toolate handler wanted to return 500 and returned 200 instead. If you see that warning in your logs, a handler is writing the body before deciding the status, usually because an error path calls http.Error after something already wrote output. Notice /toolate also got a Content-Type you never set: with no explicit header, the server sniffs the first 512 bytes with DetectContentType and guesses. Set Content-Type yourself for anything that is not HTML.
Two shortcuts cover most handlers. http.Error sets Content-Type: text/plain, writes the status, and writes the message in one call. http.Redirect does the same for 3xx. Reach for them instead of building error responses by hand.
Reading requests: path values, query, headers, and body
Everything the client sent hangs off *http.Request. The four you touch constantly:
mux.HandleFunc("POST /projects/{projectID}/notes", func(w http.ResponseWriter, r *http.Request) {
projectID := r.PathValue("projectID") // from the route pattern
limit := r.URL.Query().Get("limit") // ?limit=10
auth := r.Header.Get("Authorization") // request header
body, err := io.ReadAll(r.Body) // server closes r.Body after ServeHTTP returns
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
fmt.Fprintf(w, "project=%s limit=%s auth=%s body=%q\n",
projectID, limit, auth, strings.TrimSpace(string(body)))
})
Sending POST /projects/apollo/notes?limit=10 with an Authorization header and a text body:
project=apollo limit=10 auth=Bearer t0ken body="ship the release"
About r.Body: it is an io.ReadCloser, and the server closes it for you after ServeHTTP returns, so in a handler you do not need defer r.Body.Close(). That defer matters on the client side, where you own the response body. Two habits keep body handling safe. Cap the read with http.MaxBytesReader so a client cannot stream gigabytes into memory, and if you read the body, read it to completion (or close it early), because leaving bytes unread can prevent the connection from being reused for keep-alive.
Serving JSON is three lines, and the order still matters
Most real servers speak JSON. Encoding directly to the ResponseWriter streams the response without buffering the whole thing:
func getUserHandler(w http.ResponseWriter, r *http.Request) {
u := user{ID: 42, Name: "Ada"}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(u); err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
return
}
}
Output:
Content-Type: application/json
body: {"id":42,"name":"Ada"}
Set Content-Type: application/json before you encode, because Encode writes the body and locks the status. One honest caveat: if encoding fails partway through a large payload, the 200 is already sent and the http.Error fallback triggers the superfluous-WriteHeader warning from earlier. For payloads you can hold in memory, json.Marshal into a []byte first, check the error, then write, so a marshal failure becomes a clean 500. Struct tags, streaming, and custom marshaling are their own topic, covered in the Go JSON guide.
http.ListenAndServe has no timeouts, and that is a production bug
Nearly every tutorial ends with this line:
http.ListenAndServe(":8080", mux) // do not ship this
It works on your laptop and exposes you to a trivial denial of service in production. ListenAndServe builds an http.Server with zero timeouts, which means a client can open a connection, send one byte of headers per minute, and hold a goroutine and a file descriptor open indefinitely. That is a Slowloris attack, and the defense is one struct. Configure an http.Server explicitly:
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 200 * time.Millisecond, // small here to test; use ~5s in prod
ReadTimeout: 1 * time.Second,
WriteTimeout: 1 * time.Second,
IdleTimeout: 30 * time.Second,
}
What each one bounds:
ReadHeaderTimeout: how long a client has to send the complete request headers. This is the direct Slowloris defense and the one every server should set.ReadTimeout: the whole request including body. Prevents a slow or stalled upload from pinning a connection.WriteTimeout: how long your handler has to write the full response. Caps slow-consumer clients and runaway handlers.IdleTimeout: how long an idle keep-alive connection stays open between requests. Without it, idle connections accumulate.
Here is the header timeout doing its job. Open a raw TCP connection, send a partial request that never finishes its headers, and watch the server hang up:
conn, _ := net.Dial("tcp", ln.Addr().String())
fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: x\r\n") // note: no final blank line
start := time.Now()
buf, _ := io.ReadAll(conn) // blocks until the server gives up and closes
fmt.Printf("server closed the slow connection after %v\n", time.Since(start).Round(50*time.Millisecond))
fmt.Printf("bytes returned to the slow client: %d\n", len(buf))
Output:
server closed the slow connection after 200ms
bytes returned to the slow client: 0
The connection is reclaimed at exactly ReadHeaderTimeout. With the default ListenAndServe, that read blocks until the client goes away, and a few thousand such connections exhaust your descriptors. In production, set ReadHeaderTimeout to about 5 seconds and size the rest to your real request patterns. There is no safe default of zero.
Serving static files with FileServer, and the traversal trap
http.FileServer turns a directory into a handler. Pair it with http.StripPrefix so the URL prefix is removed before the filesystem lookup:
fs := http.FileServer(http.Dir(public))
mux.Handle("GET /static/", http.StripPrefix("/static/", fs))
The natural worry is directory traversal: can a client ask for /static/../secret.txt and read files above your public root? Tested against a secret.txt sitting one level up:
/static/style.css -> 200 "body{color:green}"
/static/../secret.txt -> 404 "404 page not found"
/static/%2e%2e/secret.txt -> 404 "404 page not found"
Both traversal attempts, raw and percent-encoded, get a 404. The ServeMux cleans .. segments out of the path before routing, and http.Dir rejects any request whose cleaned path would escape the root. You get this protection for free from the standard library. What http.Dir does not do is hide dotfiles: a .env or .git under your served directory is reachable. Serve only a directory that contains nothing secret, and keep configuration and source outside it.
Graceful shutdown: drain in-flight requests, then exit
Killing a server with os.Exit drops every in-flight request: uploads truncate, writes half-commit, clients see resets. http.Server.Shutdown does it properly. It stops accepting new connections, lets active requests finish, and returns once they drain or the context you pass expires. Wire it to SIGINT and SIGTERM with signal.NotifyContext:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(ln) }()
<-ctx.Done() // block until the signal arrives
fmt.Println("signal received, shutting down")
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
fmt.Println("shutdown error:", err)
}
To prove it drains rather than drops, the test starts a handler that sleeps 300ms, fires a request at it, delivers a real SIGTERM 100ms into that request, then checks the outcome:
signal received, shutting down
in-flight request got 200: slow work done
post-shutdown request refused as expected
The in-flight request completed with 200 even though the shutdown signal arrived while it was still running, and the next request after shutdown was refused. That is the behavior a load balancer relies on during a rolling deploy: drain the old instance, do not reset live connections. The context.WithTimeout on Shutdown is your escape hatch. If a handler never returns, shutdown waits at most 5 seconds and then you fall back to srv.Close(), which drops the stragglers.
Per-request context bounds slow work, and cancels on disconnect
Every request carries a context.Context via r.Context(). It is cancelled when the client disconnects or (if you set WriteTimeout) when the response deadline passes. Derive a tighter deadline from it to cap downstream work, so one slow database query cannot pin a handler forever:
func reportHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 150*time.Millisecond)
defer cancel()
result, err := fetchReport(ctx, workDuration(r))
if err != nil {
http.Error(w, "upstream timed out", http.StatusGatewayTimeout)
return
}
fmt.Fprintln(w, result)
}
fetchReport selects on ctx.Done(), so when the 150ms deadline fires it returns ctx.Err() and the handler responds 504:
/report -> 200 "report ready"
/report?slow=xxx -> 504 "upstream timed out"
Passing r.Context() down through every call (database, HTTP client, queue) is the single most important habit for a server that stays responsive under load. When the caller gives up, the whole chain of work behind that request unwinds instead of piling up. The mechanics of contexts, deadlines, and cancellation are covered in the context guide.
Putting it together: a complete configured server
Here is the whole thing assembled: a router, a configured server with real timeouts, and graceful shutdown. No third-party imports.
package main
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
type user struct {
ID string `json:"id"`
Name string `json:"name"`
}
func newRouter() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
u := user{ID: r.PathValue("id"), Name: "Ada Lovelace"}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(u); err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
}
})
return mux
}
func newServer(addr string) *http.Server {
return &http.Server{
Addr: addr,
Handler: newRouter(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
srv := newServer(":8080")
go func() {
log.Printf("listening on %s", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
<-ctx.Done()
log.Println("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
srv.Close()
}
log.Println("server stopped")
}
Two details make this testable and correct. newRouter returns an http.Handler and takes no globals, so a test can exercise it with httptest and never bind a port. And ListenAndServe returns http.ErrServerClosed on a clean shutdown, which is not a real error, so the goroutine checks for it with errors.Is before calling log.Fatalf.
Exercising newRouter through httptest.NewServer:
GET /healthz -> 200 "{\"status\":\"ok\"}"
GET /users/7 -> 200 "{\"id\":\"7\",\"name\":\"Ada Lovelace\"}"
DELETE /users/7 -> 405 "Method Not Allowed"
GET /missing -> 404 "404 page not found"
And the lifecycle, built and sent a real SIGTERM:
2026/07/20 20:48:06 listening on :8080
2026/07/20 20:48:06 shutdown signal received
2026/07/20 20:48:06 server stopped
That is a server you can put behind a load balancer. To grow it into a full resource API with create, update, delete, validation, and structured errors, follow the REST API in Go tutorial, which builds on exactly this foundation. Cross-cutting concerns like logging, auth, and the panic recovery every server needs belong in reusable HTTP middleware, which wraps the http.Handler you already have.
Common mistakes
Using http.ListenAndServe and DefaultServeMux by habit. The package-level helpers (http.Handle, http.HandleFunc, http.ListenAndServe) register on a global DefaultServeMux and build a server with no timeouts. The global mux means any package you import can register routes on your server, which is a real supply-chain and collision risk. Always create your own http.NewServeMux() and your own configured http.Server.
Shipping without server timeouts. Covered above: no ReadHeaderTimeout is an open door to Slowloris. This is the most common production net/http mistake, and it does not show up in testing because your test clients are well behaved.
Writing the body before the status. Calling w.Write (or fmt.Fprint, or json.Encode) sends a 200 and freezes the status. A later w.WriteHeader(500) is ignored and logged as superfluous. Decide the status first:
// Wrong: 200 is already on the wire before the error check runs.
json.NewEncoder(w).Encode(result)
if err != nil {
w.WriteHeader(http.StatusInternalServerError) // too late
}
// Right: marshal, check, then write status and body together.
data, err := json.Marshal(result)
if err != nil {
http.Error(w, "encode failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
Blocking a handler on slow work with no timeout. A handler that calls a database or another service with no deadline holds a goroutine until that call returns, however long that takes. Under a traffic spike this pins goroutines faster than they drain and the server stops responding. Derive a deadline from r.Context() as shown earlier, and pass that context into every downstream call.
Assuming r.Body is unlimited or self-cleaning on the client side. In a handler the server closes r.Body for you, but it does not cap its size: wrap it with http.MaxBytesReader before reading untrusted input. And remember the asymmetry: on the client side of an HTTP call, closing the response body is your job, and forgetting it leaks connections.
What next
You now have a standard-library server with routing, correct responses, timeouts, static files, and graceful shutdown, all tested. Where to go from here:
- Building a REST API in Go: turn these routes into a full resource API with validation and structured errors
- Writing reusable HTTP middleware: logging, authentication, and panic recovery that wrap any
http.Handler - Go JSON: encoding and decoding: struct tags, streaming, and custom marshaling for real payloads
- Context in Go: cancellation and deadlines, the backbone of a responsive server
- New to the language? The complete Go tutorial covers the fundamentals this builds on