Reuse http.Transport in Go: Connection Pools, Idle Limits, and Safe Customization
The net/http docs say it in one line: “Transports should be reused instead of created as needed.” Ignore that advice and you lose the main benefit of persistent HTTP connections: fewer TCP connections and, for HTTPS, fewer TLS handshakes.
A Transport is not just a config struct. It owns the connection cache, including idle HTTP/1 connections and HTTP/2 connections. Build a fresh &http.Transport{} per request, or a fresh &http.Client{Transport: ...} wrapping one, and every request starts with an empty pool. The connection is also left attached to a transport you no longer use until it is closed, for example by IdleConnTimeout or the peer.
One transport, shared across goroutines
Transport is documented as safe for concurrent use by multiple goroutines. So the right shape is a package-level client, created once:
package httpx
import (
"net"
"net/http"
"time"
)
var Client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
MaxIdleConns: 200,
MaxIdleConnsPerHost: 50,
MaxConnsPerHost: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
},
}
http.Client adds redirects, cookies, and the overall Timeout. Clients should usually be long-lived too, but separate clients can share one transport when they genuinely need different cookie jars or redirect policies.
If you don’t need custom fields, use http.DefaultTransport (via http.DefaultClient, or by wrapping it). It already sets MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, ProxyFromEnvironment, and it speaks HTTP/2. What it doesn’t give you is a request timeout, which is covered in why the default Go HTTP client can wait forever.
The three limits people confuse
Three fields control pool size, and they mean different things.
MaxIdleConnsPerHost is how many idle connections to keep per host. If it’s zero, Go falls back to DefaultMaxIdleConnsPerHost, and that constant is 2. Two. This is the field that bites people. You can have 500 goroutines hammering one host and Go will happily open 500 connections, but only 2 survive as idle afterwards. The other 498 get closed and re-dialed on the next burst.
MaxIdleConns is the cap on idle connections across all hosts. Zero means no limit.
MaxConnsPerHost is the cap on total connections per host, counting dialing, active, and idle. The docs are blunt about the consequence: “on limit violation, dials will block.” That makes it a throttle rather than a pool size, and blocked dials only unblock when a connection frees up or the request context is cancelled.
Calling a single backend from a high-concurrency service over HTTP/1? Set MaxIdleConnsPerHost high enough to retain the idle capacity you actually want between bursts, then confirm the effect with connection and latency metrics. Trying to protect a fragile downstream? MaxConnsPerHost can help, but pair it with request contexts that have deadlines. Otherwise, requests can wait inside the transport with little visibility. Bounding work upstream with a worker pool can make that queue easier to observe and control.
The mental model is close to how database/sql manages its connection pool: a shared pool object, idle limits, max open limits, an idle timeout.
Read and close HTTP/1 response bodies when you want reuse
The documentation is deliberately qualified here: the default transport may not reuse an HTTP/1 keep-alive connection unless the response body is read to completion and closed. Closing a partially read HTTP/1 body can therefore cost you that connection. HTTP/2 is different because abandoning a response can cancel its stream without necessarily closing the shared connection.
Which means defer resp.Body.Close() on its own is not enough once you start bailing out early:
func fetch(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := httpx.Client.Do(req)
if err != nil {
return err
}
defer func() {
// Drain so the connection goes back to the idle pool,
// then close. Cap the drain so a huge body can't stall us.
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
var out payload
return json.NewDecoder(resp.Body).Decode(&out)
}
Note the LimitReader. Draining unconditionally could read a huge error page that the caller does not need. With this cap, small remaining bodies can reach EOF and preserve an HTTP/1 connection; a larger body is closed after 1 MiB, so that connection might not be reused.
json.Decoder deserves a thought here too. It can finish after the first JSON value without proving that the response is at EOF. The deferred drain covers any unread remainder.
TLS config silently disables HTTP/2
This one catches almost everyone. From the docs on TLSClientConfig: “If non-nil, HTTP/2 support may not be enabled by default.” Custom dialers behave the same way. ForceAttemptHTTP2 exists precisely because “by default, use of any those fields conservatively disables HTTP/2.”
So this transport is HTTP/1.1 only:
tr := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13},
}
Add ForceAttemptHTTP2: true and HTTP/2 comes back. Go 1.24 and later give you a clearer knob, the Protocols field:
tr := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS13},
}
tr.Protocols = &http.Protocols{}
tr.Protocols.SetHTTP1(true)
tr.Protocols.SetHTTP2(true)
Protocols also retires the old trick of setting TLSNextProto to an empty non-nil map to switch HTTP/2 off. Include UnencryptedHTTP2 without HTTP1 and the transport speaks cleartext HTTP/2 for http:// URLs; Go 1.24’s h2c support covers why that got easier.
HTTP/2 changes the pooling picture as well. One connection can multiplex many streams, so HTTP/1-style idle-connection tuning is often less important. Go may open additional HTTP/2 connections when a server’s concurrent-stream limit is reached; advanced stream and flow-control settings live under Transport.HTTP2.
Proxies are part of the transport, not the request
Proxy is a func(*http.Request) (*url.URL, error) on the transport, consulted per request. One shared transport can route different hosts differently without cloning anything:
tr.Proxy = func(req *http.Request) (*url.URL, error) {
if strings.HasSuffix(req.URL.Hostname(), ".internal") {
return nil, nil // no proxy
}
return url.Parse("http://proxy.example.com:3128")
}
Returning nil, nil means no proxy. Supported schemes are http, https, socks5, and socks5h; an empty scheme is treated as http. Userinfo in the proxy URL turns into a Proxy-Authorization header.
For HTTPS through a proxy the transport issues a CONNECT. ProxyConnectHeader sets headers on that tunnel request, GetProxyConnectHeader does the same for dynamic values, and OnProxyConnectResponse runs before the 200 OK check so you can inspect or reject what the proxy sent back.
Customize per request without cloning the client
Auth headers, tracing, retries: wrap the transport in a RoundTripper rather than building a new client. The wrapper holds a reference to the shared base, so pooling is untouched.
type authTransport struct {
base http.RoundTripper
token func() string
}
func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// RoundTrippers must not modify the request they're given.
clone := req.Clone(req.Context())
clone.Header.Set("Authorization", "Bearer "+a.token())
return a.base.RoundTrip(clone)
}
That req.Clone matters because RoundTripper implementations should not mutate the incoming request, apart from consuming and closing its body. Here the clone gives the wrapper its own header map before it sets Authorization. Bear in mind that Clone only makes a shallow copy of the request body.
If you truly need a second transport with different settings, Transport.Clone() returns a deep copy of the exported fields. Derive a variant from http.DefaultTransport instead of hand-rolling every field:
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.MaxIdleConnsPerHost = 64
tr.ResponseHeaderTimeout = 5 * time.Second
Two things about Clone. It copies configuration, not the connection cache, so the clone starts with an empty pool. And a handful of long-lived clones is fine; one clone per request is the same mistake as one transport per request, wearing a disguise.
Shutting down
Idle connections hold file descriptors. If you spin up a short-lived transport for a one-off task, a migration, a CLI subcommand, a test, call CloseIdleConnections() when you’re done. It closes idle keep-alive connections and leaves in-flight requests alone. Long-lived servers rarely need it; IdleConnTimeout does the cleanup. It’s also handy during graceful shutdown when you want outbound sockets released promptly after draining.
One last detail from the docs explains why pooling usually looks seamless. After a network error on a previously used connection, the transport retries only when the request is considered idempotent: GET, HEAD, OPTIONS, TRACE, or a request carrying an Idempotency-Key or X-Idempotency-Key header. A request with a body must also provide Request.GetBody so the body can be replayed. This can hide some stale pooled connections from callers. For POST requests, opt in only when the operation is genuinely safe to replay, provide an idempotency key that the server honours, and make sure the body is replayable.