Go HTTP Cheat Sheet for Servers and Clients in One Page
A one page Go HTTP cheat sheet: server settings, routing, JSON helpers, middleware, shutdown, a safe client, status codes and headers, all run together.

This page is the version of the HTTP articles you can keep open in a second window: server settings, routing, JSON, middleware, shutdown, a safe client, and the status codes and headers you reach for most. Each section has a few lines of Go and a pointer to the article that explains the reasoning. All of the code below is one program, split into files by topic, and a driver exercises every piece against a real listener, so what you copy is code that ran on Go 1.25.6.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The full reasoning lives in the web cluster that ends in a production HTTP server. For the client side start with the client defaults, and for the error messages you will meet, see common server errors.
Server: the settings
package main
import (
"context"
"encoding/json"
"errors"
"net"
"net/http"
"time"
)
// NewServer is the baseline http.Server: every field here is a decision, not decoration.
func NewServer(h http.Handler) *http.Server {
return &http.Server{
Handler: h,
ReadHeaderTimeout: 5 * time.Second, // slow clients cannot hold a goroutine with a trickle of headers
ReadTimeout: 15 * time.Second, // whole request including body; raise for upload routes
WriteTimeout: 30 * time.Second, // whole response; use ResponseController for streaming routes
IdleTimeout: 60 * time.Second, // keep-alive connections that go quiet
MaxHeaderBytes: 16 << 10, // the default is 1 MB
}
}
// Run serves on ln until ctx ends, then drains in-flight requests for up to grace.
func Run(ctx context.Context, srv *http.Server, ln net.Listener, grace time.Duration) error {
errc := make(chan error, 1)
go func() { errc <- srv.Serve(ln) }()
select {
case err := <-errc:
return err
case <-ctx.Done():
}
shutCtx, cancel := context.WithTimeout(context.Background(), grace)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
srv.Close() // the grace period ran out: cut the remaining connections
return err
}
if err := <-errc; !errors.Is(err, http.ErrServerClosed) {
return err // ErrServerClosed after Shutdown is success
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status) // status first, then the body
json.NewEncoder(w).Encode(v)
}
func fail(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// decode reads a JSON body with a size cap and without tolerating unknown fields.
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(dst); err != nil {
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
fail(w, http.StatusRequestEntityTooLarge, "body too large")
} else {
fail(w, http.StatusBadRequest, "invalid JSON body")
}
return false
}
return true
}| Field | What it bounds | Starting value |
|---|---|---|
ReadHeaderTimeout | Time to read the request line and headers | 5 s |
ReadTimeout | Reading the whole request, body included | 15 s, longer for uploads |
WriteTimeout | Writing the whole response | 30 s, lifted per route for streams |
IdleTimeout | A keep-alive connection waiting for its next request | 60 s, below any load balancer's |
MaxHeaderBytes | Size of request headers | 16 KB (plus 4 KB of slack the server adds) |
Shutdown(ctx) | Waits for active requests, then closes | 10 to 30 s grace |
The reasoning is in server timeouts and graceful shutdown.
Server: routing and middleware
package main
import (
"fmt"
"net/http"
"strconv"
"time"
)
type Middleware func(http.Handler) http.Handler
func Chain(h http.Handler, m ...Middleware) http.Handler {
for i := len(m) - 1; i >= 0; i-- { // the first in the list is the outermost
h = m[i](h)
}
return h
}
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
fail(w, http.StatusInternalServerError, "internal error")
}
}()
next.ServeHTTP(w, r)
})
}
func RequestID(next http.Handler) http.Handler {
var n int
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
n++
id = fmt.Sprintf("req-%d", n)
}
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r)
})
}
func Timeout(d time.Duration) Middleware {
return func(next http.Handler) http.Handler { return http.TimeoutHandler(next, d, `{"error":"timeout"}`) }
}
func Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]string{"service": "sheet"}) })
mux.HandleFunc("GET /items/{id}", func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(r.PathValue("id"))
if err != nil {
fail(w, http.StatusBadRequest, "id must be a number")
return
}
writeJSON(w, 200, map[string]any{"id": id, "page": r.URL.Query().Get("page")})
})
mux.HandleFunc("POST /items", func(w http.ResponseWriter, r *http.Request) {
var in struct {
Name string `json:"name"`
}
if !decode(w, r, &in) {
return
}
w.Header().Set("Location", "/items/1")
writeJSON(w, http.StatusCreated, map[string]any{"id": 1, "name": in.Name})
})
mux.HandleFunc("GET /files/{path...}", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]string{"path": r.PathValue("path")})
})
mux.HandleFunc("GET /boom", func(w http.ResponseWriter, r *http.Request) { panic("handler bug") })
return Chain(mux, RequestID, Recover)
}| Pattern | Matches |
|---|---|
GET /items/{id} | One segment, read with r.PathValue("id"); GET also answers HEAD |
GET /files/{path...} | The rest of the path, slashes included |
GET /{$} | The root and nothing else |
/static/ | Everything under the prefix (a trailing slash means a subtree) |
api.example.com/v1/ | Only that host; the port and nothing about case is stripped, so lowercase it first |
Wrong method on a known path gives 405 with Allow; unknown path gives 404. More in the Go 1.22 mux article, middleware and route organization.
Client: the safe default
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// One client for the program: the Transport inside it is the connection pool.
var client = &http.Client{Timeout: 10 * time.Second}
// do sends a request and decodes a JSON answer. It checks the status, bounds the read and drains the body.
func do(ctx context.Context, method, url, body string, out any) (int, error) {
req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(body))
if err != nil {
return 0, err
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
res, err := client.Do(req)
if err != nil {
return 0, err
}
defer res.Body.Close()
data, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return res.StatusCode, err
}
if res.StatusCode >= 300 {
return res.StatusCode, fmt.Errorf("%s %s: %s %s", method, url, res.Status, strings.TrimSpace(string(data)))
}
if out != nil {
return res.StatusCode, json.Unmarshal(data, out)
}
return res.StatusCode, nil
}The client checklist: a Timeout, one shared instance, NewRequestWithContext, a status check (a 500 is not an err), defer res.Body.Close(), a bounded read, and retries only for idempotent requests. The reasons are in the client defaults, transport settings and retries.
Everything, run together
package main
import (
"context"
"fmt"
"net"
"strings"
"time"
)
func main() {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
base := "http://" + ln.Addr().String()
ctx, stop := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- Run(ctx, NewServer(Routes()), ln, 5*time.Second) }()
show := func(label, method, path, body string) {
var out map[string]any
status, err := do(context.Background(), method, base+path, body, &out)
text := fmt.Sprint(out)
if err != nil {
text = strings.ReplaceAll(err.Error(), base, "")
}
fmt.Printf("%-26s %3d %s\n", label, status, text)
}
show("root", "GET", "/", "")
show("path value and query", "GET", "/items/7?page=2", "")
show("bad path value", "GET", "/items/abc", "")
show("create", "POST", "/items", `{"name":"widget"}`)
show("unknown JSON field", "POST", "/items", `{"name":"x","admin":true}`)
show("malformed JSON", "POST", "/items", `{"name":`)
show("wildcard with slashes", "GET", "/files/a/b/c.txt", "")
show("wrong method", "DELETE", "/items/7", "")
show("unknown path", "GET", "/nope", "")
show("panic recovered", "GET", "/boom", "")
res, _ := client.Get(base + "/")
res.Body.Close()
fmt.Println("request ID header set:", res.Header.Get("X-Request-Id") != "")
stop() // the signal.NotifyContext in a real main
fmt.Println("Run returned:", <-done)
}root 200 map[service:sheet]
path value and query 200 map[id:7 page:2]
bad path value 400 GET /items/abc: 400 Bad Request {"error":"id must be a number"}
create 201 map[id:1 name:widget]
unknown JSON field 400 POST /items: 400 Bad Request {"error":"invalid JSON body"}
malformed JSON 400 POST /items: 400 Bad Request {"error":"invalid JSON body"}
wildcard with slashes 200 map[path:a/b/c.txt]
wrong method 405 DELETE /items/7: 405 Method Not Allowed Method Not Allowed
unknown path 404 GET /nope: 404 Not Found 404 page not found
panic recovered 500 GET /boom: 500 Internal Server Error {"error":"internal error"}
request ID header set: true
Run returned: <nil>Status codes worth knowing
| Code | Use it when |
|---|---|
200 OK | The request worked and there is a body |
201 Created | A POST created something; send Location |
202 Accepted | Work was queued and is not finished |
204 No Content | Success with nothing to return (DELETE, some PUT) |
301 / 308 | Permanent move; 308 keeps the method |
302 / 307 | Temporary move; 307 keeps the method; 303 after a form POST |
304 Not Modified | A conditional request matched; no body |
400 Bad Request | The request cannot be understood (malformed JSON, bad path value) |
401 Unauthorized | No or invalid credentials; send WWW-Authenticate |
403 Forbidden | Credentials are fine, permission is not |
404 Not Found | No such resource (and for resources you hide) |
405 Method Not Allowed | Known path, wrong method; send Allow |
409 Conflict | The request clashes with current state |
410 Gone | Existed once, deliberately removed or expired |
413 Content Too Large | Body over your limit |
422 Unprocessable Content | Well-formed but semantically invalid (validation) |
429 Too Many Requests | Rate limited; send Retry-After |
500 Internal Server Error | A bug; log it, send a request ID, never the cause |
502 / 503 / 504 | Bad upstream, overloaded or down, upstream timeout |
The long list with advice is in status codes in Go.
Habits that save an incident
A short list to read before you ship. Set ReadHeaderTimeout on every server, because its default is no limit. End every error response with return, and test that the handler stops. Put the body size limit before the first read of the body, not after. Make shutdown the way your process exits, and treat http.ErrServerClosed as success. Give each outgoing request a deadline, from a context or from Client.Timeout. Close and drain every response body. Log the request ID on both sides of a call. And decide, for each endpoint that changes state, what happens when the same request arrives twice, because retries and double clicks make that a certainty and not a possibility.
Headers worth setting
| Header | Where | Why |
|---|---|---|
Content-Type | Both | Say what the body is, with charset=utf-8 for text |
Cache-Control | Response | no-cache for pages, long max-age for fingerprinted assets; see cache headers |
ETag, If-None-Match | Both | Conditional requests and 304s; see ETags |
Location | Response | Where a created or moved resource lives |
Retry-After | Response | When to try again after 429 or 503 |
Allow | Response | The methods a path accepts (added to 405 by the mux) |
Vary | Response | Request headers the response depends on |
X-Content-Type-Options: nosniff | Response | Stop browsers guessing a type for user content |
Authorization | Request | Credentials, never logged |
X-Request-Id | Both | One identifier through logs on both sides |
For the Go documentation itself, keep the net/http package reference at hand, and MDN's HTTP reference for the protocol side.


