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 host after resource exhaustion
  • runtime: out of memory from leaked goroutine stacks
  • too many open files error
  • netstat shows thousands of connections in ESTABLISHED state
go
// 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 defer inside a loop without understanding delayed execution

Step-by-Step Fix

  1. 1.Always close body with defer immediately after error check:
  2. 2.```go
  3. 3.resp, err := http.Get("https://api.example.com/data")
  4. 4.if err != nil {
  5. 5.log.Fatal(err)
  6. 6.}
  7. 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. 1.For error-only responses, drain and close:
  2. 2.```go
  3. 3.resp, err := http.Get("https://api.example.com/data")
  4. 4.if err != nil {
  5. 5.return err
  6. 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. 1.Fix defer inside loops:
  2. 2.```go
  3. 3.// WRONG - defer accumulates, bodies not closed until function returns
  4. 4.for _, url := range urls {
  5. 5.resp, err := http.Get(url)
  6. 6.if err != nil { continue }
  7. 7.defer resp.Body.Close() // All deferred, none closed during loop
  8. 8.process(resp)
  9. 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. 1.Detect goroutine leaks in tests:
  2. 2.```go
  3. 3.import (
  4. 4."runtime"
  5. 5."testing"
  6. 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() or io.Copy(io.Discard, ...) before closing if you don't need the body
  • Enable GODEBUG=http2debug=2 to debug HTTP/2 connection behavior
  • Set http.Client.Timeout to prevent hanging connections
  • Monitor goroutine count in production with runtime.NumGoroutine() metrics
  • Use goleak library in tests: defer goleak.VerifyNone(t)