open atlas
↑ Back to track
Go, zero to senior GO · 03 · 03

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.

GO Senior ◷ 19 min
Level
FoundationsJuniorMiddleSenior

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.

Quiz

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.

Quiz

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.

Recall before you leave
  1. 01
    Name the four http.Server timeout fields, what phase each guards, and the sharp edge of WriteTimeout.
  2. 02
    An outbound call must respect a 2-second budget end to end. Walk through where you enforce it and the defaults that would betray you.
Recap

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.

recallapplystretch0 of 6 done

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.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.