Skip to content
gohacks.dev
Web

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.

8 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

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

server.goGo
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
}
FieldWhat it boundsStarting value
ReadHeaderTimeoutTime to read the request line and headers5 s
ReadTimeoutReading the whole request, body included15 s, longer for uploads
WriteTimeoutWriting the whole response30 s, lifted per route for streams
IdleTimeoutA keep-alive connection waiting for its next request60 s, below any load balancer's
MaxHeaderBytesSize of request headers16 KB (plus 4 KB of slack the server adds)
Shutdown(ctx)Waits for active requests, then closes10 to 30 s grace

The reasoning is in server timeouts and graceful shutdown.

Server: routing and middleware

routes.goGo
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)
}
PatternMatches
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

client.goGo
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

main.goGo
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)
}
Text
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

CodeUse it when
200 OKThe request worked and there is a body
201 CreatedA POST created something; send Location
202 AcceptedWork was queued and is not finished
204 No ContentSuccess with nothing to return (DELETE, some PUT)
301 / 308Permanent move; 308 keeps the method
302 / 307Temporary move; 307 keeps the method; 303 after a form POST
304 Not ModifiedA conditional request matched; no body
400 Bad RequestThe request cannot be understood (malformed JSON, bad path value)
401 UnauthorizedNo or invalid credentials; send WWW-Authenticate
403 ForbiddenCredentials are fine, permission is not
404 Not FoundNo such resource (and for resources you hide)
405 Method Not AllowedKnown path, wrong method; send Allow
409 ConflictThe request clashes with current state
410 GoneExisted once, deliberately removed or expired
413 Content Too LargeBody over your limit
422 Unprocessable ContentWell-formed but semantically invalid (validation)
429 Too Many RequestsRate limited; send Retry-After
500 Internal Server ErrorA bug; log it, send a request ID, never the cause
502 / 503 / 504Bad 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

HeaderWhereWhy
Content-TypeBothSay what the body is, with charset=utf-8 for text
Cache-ControlResponseno-cache for pages, long max-age for fingerprinted assets; see cache headers
ETag, If-None-MatchBothConditional requests and 304s; see ETags
LocationResponseWhere a created or moved resource lives
Retry-AfterResponseWhen to try again after 429 or 503
AllowResponseThe methods a path accepts (added to 405 by the mux)
VaryResponseRequest headers the response depends on
X-Content-Type-Options: nosniffResponseStop browsers guessing a type for user content
AuthorizationRequestCredentials, never logged
X-Request-IdBothOne 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.