Go http.Client Timeout Not Working or Leaking Connections
Two distinct but commonly confused issues affect Go's http.Client: a configured timeout that doesn't actually seem to apply, and connections that leak over time — both usually trace back to how the response body is (or isn't) handled after a request completes.
The Problem
Requests using a configured http.Client with a set timeout still seem to hang far longer than expected, or, separately, the application's connection pool gradually exhausts over time:
Get "https://api.example.com/data": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
Or, for the leak scenario, no explicit error at first, but eventually:
dial tcp: lookup api.example.com: too many open files
Why It Happens
Both issues, despite looking different, often trace back to the same root cause: not properly closing the response body after a request. Specific mechanisms:
- Go's HTTP transport reuses underlying TCP connections for future requests (connection pooling) specifically when a response body is fully read and closed — failing to close it prevents that connection from being returned to the pool, and over many requests, this steadily exhausts available file descriptors
- A client-level
Timeoutfield covers the entire request/response cycle including reading the body, so if code reads a response but never sets a deadline on reading a very large or slow-streaming body separately, the overall client timeout is still the only thing eventually stopping it — but if the timeout itself isn't actually configured on the client being used, requests can hang far longer than any assumed default - Using the default
http.Get()/http.Post()package-level functions, which usehttp.DefaultClient— a client with no timeout configured at all by default, meaning requests can hang indefinitely unless a timeout is explicitly set somewhere in the chain - Creating a new
http.Clientfor every single request rather than reusing one, which defeats connection pooling entirely regardless of whether bodies are properly closed, since each new client starts with its own empty connection pool
The Fix
Always close the response body, using defer immediately after checking the error from the request, to guarantee it happens regardless of what follows:
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
This single pattern resolves the majority of connection leak issues — the deferred close ensures the connection can be returned to the pool for reuse (assuming the body was also fully read), regardless of whether subsequent code succeeds or encounters an error.
Explicitly configure a timeout on your http.Client rather than relying on package-level default functions, which use a client with no timeout at all:
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
Create and reuse a single http.Client instance across your application (or at least across all requests to a given service), rather than constructing a new one for every request — clients are safe for concurrent use by multiple goroutines, and reusing one is what actually enables connection pooling to provide its intended performance benefit:
// Package-level or struct-field client, created once
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)
}
For more granular control over different phases of a request (connection establishment versus reading the response), configure the underlying Transport directly rather than relying solely on the client-level overall Timeout:
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
}
client := &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
}
MaxIdleConnsPerHost in particular is worth tuning explicitly for applications making many concurrent requests to the same host, since Go's default value here (2) is quite conservative and can itself become a bottleneck for legitimately high-concurrency workloads talking to a single backend repeatedly.
Still Not Working?
If you're closing response bodies correctly but still suspect a leak, verify by monitoring the actual number of open file descriptors your process holds over time, correlating any growth with specific application activity to identify a code path that might still be missing a proper close, perhaps on a less common error branch that a code review missed:
ls /proc/<pid>/fd | wc -l
Running this periodically while exercising different code paths in your application (including deliberately triggering error conditions, not just the happy path) can reveal a leak specifically tied to an error-handling branch that a normal, successful-request-only test wouldn't have exposed.