Introduction
The Go http.Client reuses TCP connections via a connection pool. For connection reuse to work, the response body must be fully read and closed. If you only check the status code without reading and closing the body, the underlying goroutine that reads the response never completes, and the TCP connection is not returned to the pool. Over time, this exhausts file descriptors and goroutine count climbs.
Symptoms
goroutine profile: total 50000(growing over time)dial tcp: lookup api.example.com: no such hostafter resource exhaustionruntime: out of memoryfrom leaked goroutine stackstoo many open fileserrornetstatshows thousands of connections in ESTABLISHED state
// This leaks a goroutine and a connection
resp, err := http.Get("https://api.example.com/data")
if err != nil {
log.Fatal(err)
}
if resp.StatusCode != 200 {
return // GOROUTINE LEAK - body never read or closed
}Common Causes
- Not calling
response.Body.Close()at all - Calling
Close()but not reading the body before closing - Returning early on error paths without closing
- Only closing body on success path
- Using
deferinside a loop without understanding delayed execution
Step-by-Step Fix
- 1.Always close body with defer immediately after error check:
- 2.```go
- 3.resp, err := http.Get("https://api.example.com/data")
- 4.if err != nil {
- 5.log.Fatal(err)
- 6.}
- 7.defer resp.Body.Close() // Always defer, even if you might not read it
// Check status BEFORE reading body if resp.StatusCode != http.StatusOK { // Body will be closed by defer when function returns return fmt.Errorf("unexpected status: %d", resp.StatusCode) }
data, err := io.ReadAll(resp.Body) ```
- 1.For error-only responses, drain and close:
- 2.```go
- 3.resp, err := http.Get("https://api.example.com/data")
- 4.if err != nil {
- 5.return err
- 6.}
if resp.StatusCode >= 400 { io.Copy(io.Discard, resp.Body) // Drain to allow connection reuse resp.Body.Close() return fmt.Errorf("API error: %d", resp.StatusCode) }
defer resp.Body.Close() return json.NewDecoder(resp.Body).Decode(&result) ```
- 1.Fix defer inside loops:
- 2.```go
- 3.// WRONG - defer accumulates, bodies not closed until function returns
- 4.for _, url := range urls {
- 5.resp, err := http.Get(url)
- 6.if err != nil { continue }
- 7.defer resp.Body.Close() // All deferred, none closed during loop
- 8.process(resp)
- 9.}
// CORRECT - wrap in function so defer runs each iteration for _, url := range urls { err := func() error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() return process(resp) }() if err != nil { log.Printf("error: %v", err) } } ```
- 1.Detect goroutine leaks in tests:
- 2.```go
- 3.import (
- 4."runtime"
- 5."testing"
- 6.)
func TestNoGoroutineLeak(t *testing.T) { initial := runtime.NumGoroutine()
// Run HTTP operations makeAPIRequests()
// Force GC runtime.GC()
time.Sleep(100 * time.Millisecond) final := runtime.NumGoroutine()
if final > initial+5 { // Allow some tolerance t.Errorf("goroutine leak: started with %d, ended with %d", initial, final) } } ```
Prevention
- Always
defer resp.Body.Close()immediately after the nil check on response - Use
io.ReadAll()orio.Copy(io.Discard, ...)before closing if you don't need the body - Enable
GODEBUG=http2debug=2to debug HTTP/2 connection behavior - Set
http.Client.Timeoutto prevent hanging connections - Monitor goroutine count in production with
runtime.NumGoroutine()metrics - Use
goleaklibrary in tests:defer goleak.VerifyNone(t)