Go's HTTP Connection Pool: The Key Is the Server, Not the Endpoint
Go's HTTP Connection Pool: The Key Is the Server, Not the Endpoint
I configured MaxIdleConnsPerHost: 10 on a client, added a custom DialContext to control the connection timeout, and shipped it. It looked like tuning. It was cargo cult. When I finally measured it, my "tuned" client was opening three sockets where the stock one opened a single connection — and speaking a different protocol than I thought. Everything below is checked against the Go 1.25.3 source.
Layer one: Client, Transport, and the state that actually costs money
Start with what http.Client really is:
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}Four fields, and not one of them is a URL. A Client is policy: redirects, cookies, an end-to-end deadline. It holds no sockets, no maps, no pool. All the expensive state — the idle-connection map, the LRU list, the wait queues, the sockets themselves — lives in http.Transport.
And when Transport is nil, Go doesn't build you a fresh one. It uses http.DefaultTransport, a package-level global shared by every piece of code in the process that didn't bring its own.
That gives you a genuinely counterintuitive result. Creating an http.Client per request does not break pooling — every one of those clients falls through to the same DefaultTransport and reuses the same sockets. Creating an http.Transport per request destroys it completely. I measured exactly that: a fresh Transport per request against the same host gave me 3 new sockets and zero reuse for 3 requests. The pool is per-Transport, so a per-request Transport is a pool of one connection that dies immediately.
The rule is short: share the Transport, not the Client. A Client is cheap; a Transport is the pool.
Layer two: the key is the server, not the endpoint
This is where my mental model was wrong. I assumed the pool was keyed by something endpoint-shaped. Here is the actual key type:
// net/http/transport.go (Go 1.25.3)
type connectMethodKey struct {
proxy, scheme, addr string
onlyH1 bool
}Proxy, scheme, address, and whether this request is restricted to HTTP/1. The path is not in there. GET /users, GET /posts and GET /comments against api.example.com:443 all hash to the same entry and all compete for the same idle connections.
Once you say it out loud, the physics are obvious. A socket is a four-tuple: source IP and port, destination IP and port. It has no idea what a path is. The path is just bytes you write on the request line after the connection exists. Keying a socket pool by path would be like keying a phone-call pool by what you plan to say.
So why are scheme and proxy in the key, if they aren't part of the address either? Because the key answers exactly one question: which sockets are interchangeable for this request? An http:// connection to example.com:80 and an https:// connection to example.com:80 are not interchangeable — one carries a TLS session, the other doesn't, and handing the wrong one to a request is a protocol error, not an optimization. Same for a proxy: a socket to your proxy is a different pipe from a socket to the origin.
That framing also explains the most elegant line in the file:
func (cm *connectMethod) key() connectMethodKey {
proxyStr := ""
targetAddr := cm.targetAddr
if cm.proxyURL != nil {
proxyStr = cm.proxyURL.String()
if (cm.proxyURL.Scheme == "http" || cm.proxyURL.Scheme == "https") && cm.targetScheme == "http" {
targetAddr = "" // any destination can ride this socket
}
}
return connectMethodKey{
proxy: proxyStr,
scheme: cm.targetScheme,
addr: targetAddr,
onlyH1: cm.onlyH1,
}
}When you speak plain HTTP through an HTTP proxy, Go erases the destination from the key. It has to: on that socket you send absolute-form requests to the proxy, so the same connection can serve example.com and other.com and anything else. Blanking targetAddr is Go saying "this socket is interchangeable for every destination." Under CONNECT (HTTPS through a proxy) the tunnel is pinned to one destination, so the address stays.
The life cycle of one connection
With the key settled, the flow in getConn is straightforward:
queueForIdleConn— look for an idle connection under this key. Go takeslist[len(list)-1]: LIFO, the most recently used one. That's deliberate. The hot connection stays hot and keeps getting picked, while the cold tail of the list ages out underIdleConnTimeoutinstead of every connection being kept marginally alive by round-robin.queueForDial— no idle connection, so dial a new one, subject toMaxConnsPerHost.- Wait — if neither is possible, the goroutine parks on
idleConnWait(waiting for someone to release a connection) orconnsPerHostWait(waiting for a dial slot). It is not spinning; it's a queue, and whoever finishes first hands its connection directly to the waiter.
On the way back, tryPutIdleConn decides whether the connection survives. If the idle limits are already satisfied, it doesn't get stored — it gets closed. That's the whole loop.
One practical consequence: if you don't drain and close the response body, the connection never reaches tryPutIdleConn and never returns to the pool. Every "my pool isn't working" bug I've seen in review started here.
Layer three, part one: three limits that do three different things
These get mixed up constantly, and only one of them is a concurrency limit.
| Field | Default (zero value) | DefaultTransport | What it actually bounds |
|---|---|---|---|
MaxIdleConnsPerHost | 2 | 2 | Idle connections kept per host between requests |
MaxIdleConns | 0 (unlimited) | 100 | Idle connections across all hosts, evicted LRU |
MaxConnsPerHost | 0 (unlimited) | 0 | Total connections per host — the only one that blocks the dial |
The first two say nothing about how many requests can be in flight. They govern how many connections survive between requests. Set MaxIdleConnsPerHost: 2 and fire 50 concurrent requests: Go opens 50 connections, then throws 48 away when they finish. Nothing was throttled; you just paid for 48 handshakes you could have kept.
MaxConnsPerHost is the only knob that limits concurrency, because it makes the dial itself block. Against a local HTTP/1.1 server sleeping 300ms per request, with 4 concurrent requests:
MaxConnsPerHost | Total time | Peak concurrent requests at the server |
|---|---|---|
| 0 (unlimited) | 300ms | 4 |
| 1 | 1.21s | 1 |
| 2 | 600ms | 2 |
Perfectly linear, because in HTTP/1.1 a connection serves exactly one request at a time. Go does not do pipelining. So 50 connections means 50 requests in parallel — not 50 requests multiplexed over one socket. The pool's saving is not concurrency; it's not repeating the TCP handshake and TLS negotiation for every call. (HTTP/2 changes this: one connection multiplexes many streams, which is why the next section matters so much.)
Layer three, part two: my config had silently disabled HTTP/2
So I set MaxIdleConnsPerHost: 10, added a DialContext for the connect timeout, and measured with httptrace. Three concurrent requests to the same host:
| Setup | New conns | Reused | Protocol |
|---|---|---|---|
DefaultTransport | 1 | 2 | HTTP/2.0 |
| My custom Transport | 3 | 0 | HTTP/1.1 |
My Transport + ForceAttemptHTTP2: true | 1 | 2 | HTTP/2.0 |
My "tuned" client was three times worse than doing nothing. The cause is in Transport.protocols():
case !t.ForceAttemptHTTP2 && (t.TLSClientConfig != nil || t.Dial != nil ||
t.DialContext != nil || t.hasCustomTLSDialer()):
// Conservatively disable HTTP/2 for custom dialers and TLS configs.Setting any of TLSClientConfig, Dial, DialContext, or a custom TLS dialer turns off automatic HTTP/2 negotiation. Not because a custom dialer is incompatible with HTTP/2, but out of conservatism (Go issue 14275): back when h2 was new, Go couldn't assume a hand-rolled dialer would survive being driven by the HTTP/2 machinery, so it opted out rather than break you. There's no warning, no log line. Your client just quietly falls back to HTTP/1.1 — and with HTTP/1.1 the whole pool arithmetic changes, because now you need N sockets for N concurrent requests.
DefaultTransport escapes this because it sets ForceAttemptHTTP2: true explicitly, right next to its own DialContext. The fix in my code was one line:
tr := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true, // <- without this, everything above is HTTP/1.1
}Better still: start from http.DefaultTransport.(*http.Transport).Clone() and edit the fields you care about. You inherit the sane defaults, ForceAttemptHTTP2 included.
The snippet that told me all of this
This is the whole measurement harness. GotConn fires once per request with the connection Go decided to give you, and resp.Proto tells you what you're really speaking.
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptrace"
"sync"
"time"
)
func traceFor(id int) *httptrace.ClientTrace {
return &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
fmt.Printf("[req %d] reused=%-5v wasIdle=%-5v local=%s remote=%s\n",
id, info.Reused, info.WasIdle, info.Conn.LocalAddr(), info.Conn.RemoteAddr())
},
}
}
func fetch(client *http.Client, id int, url string) {
ctx := httptrace.WithClientTrace(context.Background(), traceFor(id))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
fmt.Println("new request:", err)
return
}
resp, err := client.Do(req)
if err != nil {
fmt.Println("do:", err)
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) // required: an undrained body never returns to the pool
fmt.Printf("[req %d] proto=%s status=%d\n", id, resp.Proto, resp.StatusCode)
}
func run(name string, client *http.Client, url string, n int) {
fmt.Printf("--- %s ---\n", name)
var wg sync.WaitGroup
for i := 1; i <= n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fetch(client, id, url)
}(i)
}
wg.Wait()
}
func main() {
const url = "https://example.com/"
run("DefaultTransport", &http.Client{}, url, 3)
custom := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
}
run("custom Transport", &http.Client{Transport: custom}, url, 3)
fixed := custom.Clone()
fixed.ForceAttemptHTTP2 = true // the one-line fix
run("custom Transport + ForceAttemptHTTP2", &http.Client{Transport: fixed}, url, 3)
}Two reused=false lines in a row against the same host is your whole diagnosis. local= changing between requests means a different socket. proto=HTTP/1.1 where you expected h2 means you tripped the protocols() case above.
-
http.Clientis policy (4 fields, no URL).http.Transportis the pool. Share the Transport; a per-request Client is harmless, a per-request Transport gives you zero reuse. - The pool key is
{proxy, scheme, addr, onlyH1}— no path. Different endpoints on the same host:port share connections, because a socket only understands IP and port. - For plain HTTP through a proxy,
key()blanks the target address: that socket is interchangeable for every destination. -
getConn→ LIFO idle reuse → dial → queue.tryPutIdleConnkeeps or closes the connection based on the idle limits, and only if you drained the body. -
MaxIdleConnsPerHost(2) andMaxIdleConns(100 inDefaultTransport) bound survival, not concurrency. OnlyMaxConnsPerHostblocks. - Setting
DialContext,Dial, orTLSClientConfigdisables HTTP/2 unless you also setForceAttemptHTTP2: true.Clone()the default Transport and you inherit it.
Tuning a connection pool without measuring it is cargo cult — I had the numbers backwards on my own code for weeks. httptrace.ClientTrace with a single GotConn hook costs three lines and answers both questions that matter: am I actually reusing connections, and which protocol am I really speaking?