Timeouts and limits: the zero values that hold connections forever
http.Server ships with zero timeouts: ReadHeader, Read, Write, and Idle all default to infinite, so one slowloris client exhausts connections. Set them, add per-request context deadlines, configure Transport timeouts on outbound clients, and cap bodies with MaxBytesReader.
The outage report read like a riddle: the API died, but CPU was 4%, memory flat, and the database idle. What was exhausted was file descriptors — 65,535 of them, nearly all in ESTABLISHED, nearly all owned by a few hundred IPs that had each opened thousands of connections and then sent one HTTP header line per minute. A textbook slowloris. The server was http.ListenAndServe(addr, mux) — which constructs an http.Server with every timeout field at its zero value, and in Go zero means no timeout at all. Each trickling connection held a goroutine and an FD indefinitely, the accept loop kept accepting until the kernel said no, and legitimate clients got connection refused. The fix was eight lines: a configured Server with ReadHeaderTimeout and friends. The postmortem’s sharpest line: nobody had decided those timeouts should be infinite — the zero value had decided for them, two years earlier, on day one.
The Server timeouts nobody sets
Why does an API die with 4% CPU and an idle database? Almost certainly because you let the zero value decide — and in Go, zero timeout means infinite. Here is what you set instead.
http.ListenAndServe(addr, h) is production-hostile for exactly one reason: you cannot set timeouts on it. The four fields that matter live on http.Server, and each guards a different phase of the connection:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // request line + headers
ReadTimeout: 10 * time.Second, // headers AND full body
WriteTimeout: 30 * time.Second, // from end-of-headers to last response byte
IdleTimeout: 120 * time.Second, // keep-alive gap between requests
}
log.Fatal(srv.ListenAndServe())- ReadHeaderTimeout is the slowloris killer: the peer must deliver the complete header block within it. Cheap to set tight (2–5 s) because honest clients send headers in one packet.
- ReadTimeout covers headers plus the entire body. A tight value here breaks legitimate large uploads — which is why many services set ReadHeaderTimeout small and ReadTimeout generous, or manage body deadlines per-route.
- WriteTimeout caps writing the response. Brutal detail: the deadline is absolute from when header reading ended, not per-write — a streaming endpoint that sends events for longer than WriteTimeout gets its connection closed mid-stream no matter how lively it is.
- IdleTimeout bounds how long a keep-alive connection may sit between requests; unset it falls back to ReadTimeout, and if that is also zero, idle connections live forever. 60–120 s is conventional.
One more honesty check: WriteTimeout does not stop your handler. When it fires, net/http closes the connection; the handler goroutine keeps computing until it finishes and its writes fail. Timeouts on the Server protect connections and FDs, not CPU.
A server sets WriteTimeout: 10s. A handler streams a long report, writing a chunk every second. At t=10s the connection dies. The handler was never idle — why did the timeout fire?
Per-request deadlines: context, and what actually cancels
Server timeouts are connection plumbing; work is bounded per request, with the context:
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
row := db.QueryRowContext(ctx, query, id) // honors the deadline
// r.Context() is also canceled if the CLIENT disconnects —
// downstream work stops when nobody is waiting for the answer.
}r.Context() is canceled when the client goes away or the connection breaks, so passing it into every database call and downstream request is free load-shedding: work for an abandoned request stops instead of completing into the void. Layer your own WithTimeout on top for per-route budgets. The discipline that makes this real: every blocking call in the handler must accept the ctx — one QueryRow instead of QueryRowContext and your 2-second budget has a hole in it. The stdlib also offers http.TimeoutHandler(h, 2*time.Second, "timeout"), which returns 503 at the deadline; honest caveat — like WriteTimeout, it cannot kill the handler goroutine, it just stops waiting for it (and its ResponseWriter hides Flusher, breaking streaming inside it).
The client side: the default http.Client waits forever
The same zero-value trap exists outbound, and it is worse because http.DefaultClient is so convenient. It has no timeout of any kind: a hung upstream holds your goroutine, its FD, and whatever request-scoped memory it captured, forever. Production config sets budgets at two levels:
client := &http.Client{
Timeout: 10 * time.Second, // hard cap: dial + TLS + headers + FULL BODY READ
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 5 * time.Second, // upstream "thinking time"
IdleConnTimeout: 90 * time.Second, // pooled conn lifetime
MaxIdleConnsPerHost: 32, // default 2 throttles fan-out
},
}Client.Timeout is the blunt instrument — it spans everything including reading the body, so it must exceed your slowest legitimate full exchange. The Transport fields cut finer: dial, TLS handshake, and time-to-first-header-byte are where hung upstreams actually hang. Two defaults bite hard in production: MaxIdleConnsPerHost is 2, so a service fanning out 200 concurrent calls to one host churns 198 fresh connections (TLS handshakes, TIME_WAIT pileup); and per-request deadlines still belong to the context — http.NewRequestWithContext(ctx, ...) — so the caller’s budget propagates instead of every hop waiting its own full 10 s.
MaxBytesReader: the limit on what you will swallow
Timeouts bound time; you must also bound bytes. An unauthenticated io.ReadAll(r.Body) is an invitation: a 2 GB JSON body is a 2 GB allocation, and a few concurrent ones OOM the pod. The stdlib answer:
func createUser(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MiB cap
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
var mbe *http.MaxBytesError
if errors.As(err, &mbe) {
http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
return // 413, and the server stops reading the rest
}
http.Error(w, "bad json", http.StatusBadRequest)
return
}
}MaxBytesReader does more than truncate: on overflow it returns *http.MaxBytesError, and it tells the Server to stop reading the connection so a hostile client cannot keep pumping. Set it per route — 1 MiB for JSON APIs, larger only on upload endpoints — and remember Content-Length is a claim, not a guarantee; the reader enforces actual bytes.
A service calls an upstream with http.DefaultClient. The upstream accepts TCP connections but never sends response headers. What happens to each calling goroutine?
▸Why this works
Why does Go default every timeout to infinity instead of shipping sane defaults? Because the stdlib cannot guess your traffic: a 5-second default ReadTimeout would break file uploads, a 30-second ResponseHeaderTimeout would break long-polling, and silently changed defaults across Go versions would be a compatibility nightmare under the Go 1 promise. Zero-as-infinite is consistent and predictable — but it transfers the obligation to you, and the cheapest way to honor it is a single newServer() / newClient() constructor in your codebase that nobody bypasses, reviewed once, used everywhere.
- 01Name the four http.Server timeout fields, what phase each guards, and the sharp edge of WriteTimeout.
- 02An outbound call must respect a 2-second budget end to end. Walk through where you enforce it and the defaults that would betray you.
The Go HTTP stack treats a zero timeout as no timeout, and http.ListenAndServe gives you no way to change that — production services construct an http.Server with four fields decided on purpose. ReadHeaderTimeout closes the slowloris hole by bounding header delivery; ReadTimeout covers the full body and must fit legitimate uploads; WriteTimeout is a single absolute deadline on the response phase that kills even healthy streams and never stops the handler goroutine, only the connection; IdleTimeout reaps parked keep-alive connections and silently inherits ReadTimeout when unset. Inside the handler, time belongs to the context: r.Context() cancels when the client disconnects, per-route WithTimeout sets the work budget, and the budget only holds if every blocking call takes the ctx — one context-free query is a hole in it. http.TimeoutHandler returns a 503 at the deadline but cannot kill the goroutine behind it. Outbound is the same trap mirrored: http.DefaultClient waits forever, so real clients set Client.Timeout as the full-exchange cap and Transport budgets for dial, TLS handshake, and response headers, raise MaxIdleConnsPerHost past its fan-out-throttling default of 2, and pass per-request contexts so deadlines propagate. Bytes need bounds too: MaxBytesReader caps the body, returns MaxBytesError for a clean 413, and stops reading a hostile stream. Now when you see an outage with healthy CPU and an idle DB, your first question should be: which timeout field is still at its zero value?
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.
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.