Concurrent fetchers leak HTTP connections. Where is the resp.Body.Close() bug?
Each goroutine fetches a URL and stores the result. Under load the process leaks HTTP connections and eventually hits too many open files.
go func(url string) {
defer wg.Done()
httpClient := &http.Client{Timeout: 5 * time.Second}
resp, err := httpClient.Get(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading body from %s: %v\n", url, err)
return
}
defer resp.Body.Close()
mu.Lock()
defer mu.Unlock()
results[url] = Result{HttpCode: resp.StatusCode, Body: b}
}(url)
Find and fix the bug.
The defer resp.Body.Close() runs after io.ReadAll, and the earlier if err != nil returns leave the body unclosed on the error path. Move the defer resp.Body.Close() to immediately after the Get error check, so every successful Get closes the body and frees the connection.
- ✗Putting the
deferClose after the body-read error check, so a read error returns with the body still open - ✗Assuming a request's body never needs closing when the body is small or already read
- ✗Believing
deferorder is cosmetic — it fixes the leak only when registered before any early return
- →Why does an unclosed
resp.Bodyprevent the underlying TCP connection from being reused? - →When can
resp.Bodybenil, and doesClosestill need a guard there?
The bug
defer resp.Body.Close() is placed too late — after io.ReadAll. If io.ReadAll returns an error, the goroutine returns before the defer is even registered, leaving the body open. An unclosed body keeps the TCP connection pinned, so http.Transport cannot return it to the pool.
go func(url string) {
defer wg.Done()
httpClient := &http.Client{Timeout: 5 * time.Second}
resp, err := httpClient.Get(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
b, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading body from %s: %v\n", url, err)
return // ❌ body still open — the defer below never runs
}
defer resp.Body.Close() // ❌ registered too late
mu.Lock()
defer mu.Unlock()
results[url] = Result{HttpCode: resp.StatusCode, Body: b}
}(url)
The fix
Register defer resp.Body.Close() right after the Get error check — before any return:
resp, err := httpClient.Get(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
defer resp.Body.Close() // ✅ closes on every exit path
b, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("Error reading body from %s: %v\n", url, err)
return // ✅ body still gets closed
}