Introduction
When using http.Client in Go, failing to close response.Body causes a goroutine leak. The HTTP transport spawns a goroutine to read the response, and if the body is never closed, that goroutine hangs indefinitely. Over time, this leaks memory and eventually exhausts the system's file descriptor limit.
This is one of the most common resource leaks in Go applications and can take days or weeks to manifest in production.
Symptoms
- Goroutine count steadily increases over time (visible via runtime.NumGoroutine)
- Application eventually hits "too many open files" error
- pprof goroutine profile shows many goroutines stuck in net/http.readLoop
Common Causes
- Response body is not closed after an HTTP request
- Body is only closed on the happy path, not on error paths
- Response is discarded entirely without reading or closing the body
Step-by-Step Fix
- 1.Always defer response.Body.Close immediately: Close the body right after checking for errors.
- 2.```go
- 3.resp, err := http.Get("https://api.example.com/data")
- 4.if err != nil {
- 5.log.Fatalf("Request failed: %v", err)
- 6.}
- 7.defer resp.Body.Close() // Must be here, before using body
body, err := io.ReadAll(resp.Body) if err != nil { log.Fatalf("Read body failed: %v", err) } ```
- 1.Handle error responses properly: Close body even on non-2xx status codes.
- 2.```go
- 3.resp, err := http.Get("https://api.example.com/data")
- 4.if err != nil {
- 5.return fmt.Errorf("request failed: %w", err)
- 6.}
- 7.defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { // Body still needs to be drained and closed io.Copy(io.Discard, resp.Body) return fmt.Errorf("unexpected status: %d", resp.StatusCode) }
data, err := io.ReadAll(resp.Body) ```
- 1.Detect goroutine leaks in tests: Use goleak to verify no goroutines are leaked.
- 2.```go
- 3.import (
- 4."testing"
- 5."go.uber.org/goleak"
- 6.)
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }
func TestHTTPHandler(t *testing.T) { resp, err := http.Get(ts.URL) if err != nil { t.Fatal(err) } defer resp.Body.Close() // If you forget defer resp.Body.Close(), test fails } ```
- 1.Use pprof to find leaked goroutines: Profile goroutines in production to identify leaks.
- 2.```go
- 3.import (
- 4._ "net/http/pprof"
- 5."runtime"
- 6.)
func main() { go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()
ticker := time.NewTicker(30 * time.Second) for range ticker.C { log.Printf("Goroutines: %d", runtime.NumGoroutine()) } } ```
Prevention
- Always defer resp.Body.Close() immediately after error check
- Use static analysis: go vet and golangci-lint bodyclose detector
- Enable goleak in test suite to catch leaks during development
- Monitor goroutine count in production as a key health metric