HTTP Transport and Connection Pool
HTTP Transport and Connection Pool
Overview
http.Client performance is mostly http.Transport: dialing, TLS, idle connection pools, HTTP/2 multiplexing. Using http.DefaultClient without timeouts is a top production mistake called out constantly in Go ops threads.
Diagram: Client stack
Client.Do(req)
│
v
Transport
├── Dial (+ TLS)
├── idle pool (per host)
└── HTTP/1.1 or HTTP/2 streams
│
v
resp.Body.Close() ──► return conn to pool
Client Stack
Client.Timeout (optional overall)
-> Transport.RoundTrip
DialContext
TLSHandshake
conn pool (idle conns per host)
HTTP/1.1 or HTTP/2
tr := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
MaxConnsPerHost: 0, // 0 = unlimited
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}Pool Behaviors
| Knob | Effect |
|---|---|
MaxIdleConns |
Global idle cache size |
MaxIdleConnsPerHost |
Default is low (2) — often raise for chatty APIs |
MaxConnsPerHost |
Hard cap; backpressure when hit |
IdleConnTimeout |
Drop idle to free FDs |
DisableKeepAlives |
Every request new dial — debug only |
Failing to Body.Close() leaks connections out of the pool.
HTTP/2
When negotiated, multiple streams share one TCP connection. Benefits under many parallel requests to one host; watch head-of-line at other layers and flow control. Debug with GODEBUG=http2debug=1 carefully.
Server Side (Brief)
srv := &http.Server{
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}Timeouts are load shedding, not pedantry.
Experiment
go mod init example
go run .package main
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"time"
)
func main() {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "ok")
}))
defer ts.Close()
tr := ts.Client().Transport.(*http.Transport).Clone()
tr.MaxIdleConnsPerHost = 5
client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
for i := 0; i < 3; i++ {
resp, err := client.Get(ts.URL)
if err != nil {
panic(err)
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
fmt.Println("requests ok", time.Now().Format(time.RFC3339))
}What to notice: Closing bodies returns conns to the idle pool; omitting close eventually exhausts FDs under load.
Try next: Load test with MaxIdleConnsPerHost=2 vs 32 against a single upstream; compare dial rates in traces.