Go — Default HTTP Client Has No Timeout: Goroutines Hang Forever
Fix goroutines parked forever in net/http when using the default client, set client timeouts coarsely and per-request via context, and verify with load.
Symptoms
- Goroutine counts grow without bound, with stacks parked in net/http persistConn.writeLoop or readLoop
- A hung upstream freezes a growing subset of requests: nothing fails, nothing times out, nothing returns
- http.Get is used directly, which uses http.DefaultClient with a zero timeout
Possible causes
- The default client sets Timeout to 0, meaning no deadline ever fires
- A single unresponsive upstream connection parks its request goroutine indefinitely
- No per-request context deadline is attached, so nothing bounds the wait externally
- Connection pool slots are consumed by hung requests, starving healthy traffic to the same host
Diagnosis steps
Confirm the parked stacks
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -c "net/http" && curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -A4 "readLoop" | head -12Interpretation
Hundreds of goroutines inside net/http internals with no application frames above them indicates requests stuck at the transport layer, unbounded by any deadline.
Audit client construction
grep -Rn "http.Get\|http.Post\|http.DefaultClient\|&http.Client{}" src/ | head -20Interpretation
Direct package-level calls and zero-value clients both mean no timeout; each site is a hang under a hung upstream.
Reproduce with a stalling endpoint
nc -l 9443 & # accepts, never responds; then hit it via the appInterpretation
A TCP server that accepts and stalls reproduces the hang deterministically and lets you verify the timeout fix against it.
Root cause
The zero value of http.Client has Timeout 0, which disables the overall request deadline. A request to an upstream that accepts the connection but never responds parks its goroutine in the transport indefinitely, consuming a pool slot and a goroutine per hung call with no failure signal.
Fix
Construct clients with an explicit Timeout for coarse bounds, add per-request deadlines with context.WithTimeout for endpoint-specific budgets, and set Transport-level knobs (ResponseHeaderTimeout, TLSHandshakeTimeout, IdleConnTimeout) to bound each phase.
Risk note
A single aggressive Timeout breaks large downloads and streaming responses. Scope tight timeouts per endpoint with context, keep the client-level bound generous.
Verify the fix
Point the service at the stalling endpoint and assert every request fails within its deadline; goroutine count returns to baseline after the failure burst.
Symptom walkthrough
Nothing fails, which is the problem. The goroutine profile fills with transport frames:
$ curl -s http://localhost:6060/debug/pprof/goroutine?debug=1 | grep -c "net/http"
3184
No application frames above net/http internals means requests are stuck at the transport — waiting on a peer that accepted the TCP connection and went silent. Reproduce it in one terminal pair:
nc -l 9443 # accepts connections, never answers
# app calls http.Get("http://localhost:9443/") using DefaultClient
That request never returns. Multiply by traffic, and pool slots plus goroutines are consumed by ghosts while healthy calls to the same host starve behind them.
Fix in practice
Two layers of deadlines — coarse on the client, precise per request:
var client = &http.Client{
Timeout: 30 * time.Second, // overall bound: dial to body read
Transport: &http.Transport{
ResponseHeaderTimeout: 10 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
}
// per-request budget, tighter than the client bound:
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
Audit every http.Get and http.Post — the package-level helpers use DefaultClient with Timeout 0. A shared, configured client also gives you connection pooling, which per-call ad-hoc clients destroy.