Go http.Client Memory Leak Due to Missing resp.Body.Close()
Quick answer
A gradually growing memory footprint in a Go application making many HTTP requests is one of the most common Go memory leak patterns, and it almost always...
A gradually growing memory footprint in a Go application making many HTTP requests is one of the most common Go memory leak patterns, and it almost always traces back to response bodies that are read but never explicitly closed — a small, easy-to-miss omission that compounds significantly over a large number of requests.
The Problem
A long-running Go service that makes frequent outbound HTTP requests shows steadily increasing memory usage over time, without an obvious single cause, eventually leading to degraded performance or an out-of-memory condition:
runtime.MemStats shows heap usage climbing steadily over hours/days of operation,
correlating with request volume rather than any single large allocation
Why It Happens
Go's HTTP client maintains a pool of underlying TCP connections for reuse across requests, specifically to avoid the overhead of establishing a new connection for every single request — but a connection can only be returned to this pool and reused once its response body has been fully read and explicitly closed. When code reads a response (or ignores it entirely) without calling resp.Body.Close():
- The underlying connection can't be returned to the pool, so it remains open and consumes resources indefinitely, rather than being reused for a future request
- Over many requests, this results in accumulating open connections and their associated buffers, which is the direct source of the growing memory footprint
- Eventually, the accumulated open connections and associated file descriptors can also hit OS-level limits, producing separate but related "too many open files" errors alongside the memory growth
- This is easy to miss in code review, since the code often appears to work perfectly correctly from a functional standpoint — the response is used correctly, the bug is purely in resource cleanup, which produces no immediately visible symptom during normal, short-duration testing
The Fix
Always close the response body, using defer immediately after checking the error from the request call, ensuring it happens regardless of what the rest of the function does afterward:
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
This is the single most important fix, and should become an automatic, reflexive pattern anywhere your code makes an HTTP request — treat defer resp.Body.Close() as a mandatory next line immediately following any successful request, the same way you'd reflexively check an error return value.
Even if you don't need to read the response body's content at all (for a request where you only care about the status code, for example), you still must both read (or discard) and close the body for the connection to be correctly returned to the pool — closing alone, without draining the body first, can prevent connection reuse in some cases depending on how much of the body remains unread:
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) // drain the body even if you don't need its content
Audit your codebase systematically for every place an HTTP request is made, confirming the close pattern is applied consistently — a linter can help catch this automatically going forward, since it's an easy pattern to forget in a single overlooked function even when it's correctly applied everywhere else:
# staticcheck catches several categories of this exact issue
staticcheck ./...
Use a single, reused http.Client instance across your application rather than creating a new one per request, which is a related best practice — this ensures the connection pooling mechanism (which relies on properly closed bodies to function) actually has a meaningful, persistent pool to reuse connections within, rather than each request effectively starting with its own fresh, empty pool:
var httpClient = &http.Client{Timeout: 10 * time.Second}
func fetchData(url string) ([]byte, error) {
resp, err := httpClient.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
Still Not Working?
If you've audited and fixed obvious cases but memory usage still grows over time, confirm the leak is genuinely tied to HTTP response handling rather than a separate cause, by monitoring open file descriptor count alongside memory usage — since unclosed response bodies specifically also leak file descriptors (via the underlying unreturned TCP connections), a correlation between growing memory and growing open file descriptor count strongly confirms this specific category of leak, while memory growth without a corresponding file descriptor increase points toward investigating a different, unrelated memory leak source instead:
ls /proc/<pid>/fd | wc -l
# monitor this alongside memory usage over time to confirm correlation