Go's HTTP Transport: The Connection Pool Rewrite and the Idle-Conn Trick

net/http.Transport keeps TCP connections alive in a per-target pool with two idle slots per host by default, and each live connection runs a readLoop and a writeLoop goroutine pair that decide when a connection may be reused.

TL;DR: A Transport is a connection pool with a small budget: two idle connections per host by default, 100 across all hosts. Each live connection runs a readLoop and a writeLoop, and a connection only becomes reusable once its response body has been read to EOF. The pool itself was just rewritten around one clientConnPoolEntry per target.

Every Go program that makes HTTP requests has, without quite realising it, a connection pool. The http.DefaultClient hands you one. It is not a “nice to have” feature bolted onto the side of RoundTrip; it is the mechanism by which HTTP requests are cheap enough to be uninteresting.

The interesting part is that the pool is small, the rules for getting a connection back into it are fussy, and — as of this tree — the whole thing has just been rewritten from the ground up.

What a Transport actually holds

The Transport struct itself is mostly configuration. The pool is one field:

go
// src/net/http/transport.go:97
type Transport struct {
	pool clientConnPool

	altMu    sync.Mutex   // guards changing altProto only
	altProto atomic.Value // of nil or map[string]RoundTripper, key is URI scheme

Everything else is knobs, and the knobs that matter for the pool are these:

go
// src/net/http/transport.go:196
	// MaxIdleConns controls the maximum number of idle (keep-alive)
	// connections across all hosts. Zero means no limit.
	MaxIdleConns int

	// MaxIdleConnsPerHost, if non-zero, controls the maximum idle
	// (keep-alive) connections to keep per-host. If zero,
	// DefaultMaxIdleConnsPerHost is used.
	MaxIdleConnsPerHost int

	// MaxConnsPerHost optionally limits the total number of
	// connections per host, including connections in the dialing,
	// active, and idle states. On limit violation, dials will block.
	//
	// Zero means no limit.
	MaxConnsPerHost int

	// IdleConnTimeout is the maximum amount of time an idle
	// (keep-alive) connection will remain idle before closing
	// itself.
	// Zero means no limit.
	IdleConnTimeout time.Duration

And the defaults, baked into DefaultTransport:

go
// src/net/http/transport.go:46
var DefaultTransport RoundTripper = &Transport{
	Proxy: ProxyFromEnvironment,
	DialContext: defaultTransportDialContext(&net.Dialer{
		Timeout:   30 * time.Second,
		KeepAlive: 30 * time.Second,
	}),
	ForceAttemptHTTP2:     true,
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

// DefaultMaxIdleConnsPerHost is the default value of [Transport]'s
// MaxIdleConnsPerHost.
const DefaultMaxIdleConnsPerHost = 2

Two idle connections per host. That is the default that surprises everybody, so let’s put it in a table before moving on:

SettingDefaultWhat it limits
MaxIdleConns100Idle connections across all hosts
MaxIdleConnsPerHost2Idle connections to a single host
MaxConnsPerHost0 (unlimited)Live connections to a single host
IdleConnTimeout90sHow long an idle connection may live

The idle-conn trick

The point of MaxIdleConnsPerHost = 2 is to bound the worst case of connection churn without pinning a socket per in-flight request. A classic bug in a service that talks to one dependency is: it fires 50 requests at one host, gets 50 connections, finishes, and then holds 50 idle sockets for the next 90 seconds. File descriptor exhaustion, for no reason. Two idle connections per host prevents that, at the cost of re-dialling when a burst of concurrency recurs.

The default is read through a helper that also handles the “negative means off” convention:

go
// src/net/http/transport.go:1056
func (t *Transport) maxIdleConnsPerHost() int {
	if v := t.MaxIdleConnsPerHost; v != 0 {
		return v
	}
	return DefaultMaxIdleConnsPerHost
}

func (t *Transport) keepAlivesDisabled() bool {
	return t.DisableKeepAlives || t.MaxIdleConnsPerHost < 0 || t.MaxIdleConns < 0
}

Note the second one: setting MaxIdleConnsPerHost: -1 is the documented way to say “never reuse a connection”, and it is handled at the same level as DisableKeepAlives.

A connection gets back into the pool through tryPutIdleConn, which is the only door in:

go
// src/net/http/transport.go:1072
func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
	if t.keepAlivesDisabled() {
		return errKeepAlivesDisabled
	}
	if pconn.isBroken() {
		return errConnBroken
	}
	pconn.markReused()
	if pconn.isClientConn {
		// internalStateHook is always set for conns created by NewClientConn.
		defer pconn.internalStateHook()
		pconn.mu.Lock()
		defer pconn.mu.Unlock()
		if !pconn.inFlight {
			panic("pconn is not in flight")
		}
		pconn.inFlight = false
		select {
		case pconn.availch <- struct{}{}:
		default:
			panic("unable to make pconn available")
		}
		return nil
	}
	return t.poolPutIdleConn(pconn)
}

Two things to notice. First, pconn.markReused() is called here, not when the connection is fetched — so “reused” means “this connection has now survived one complete request/response cycle”. Second, the whole thing is one branch away from the pool.

The pool rewrite

This tree contains a commit titled “net/http: rewrite the transport connection pool”. The old implementation was a map[string][]*persistConn of idle conns, a map[string]int of conns-per-host, a map[connectMethodKey][]*wantConn queue of waiters, plus a separate idleLRU list — several structures that had to be kept in agreement with each other. The rewrite collapses all of that into one entry per target.

go
// src/net/http/transport_pool.go:15
// clientConnPool is the connection pool state for Transport,
// split out into a separate struct for organizational purposes.
//
// Every Transport has a pool.
//
// The pool has a set of entries for targets (things we can dial).
// Entries are removed from the pool when nothing references them any more
// (no conns, no dials, no waiting requests for a conn, etc.)
// An entry manages the set of connections for a target.
//
// The pool also has an LRU of connections, used to maintain the cross-target MaxIdleConn limit.
//
// Pool entries have a channel-based lock which permits get-connection requests
// to wait for available connections and/or dial slots; see clientConnPoolEntry for details.
//
// Lock ordering is (first to last): clientConnPoolEntry >> clientConnPool >> persistConn.
//
// Locking and unlocking entries is a channel operation, so we take some care to minimize
// the number of lock operations required in the hot path.
//
// The pool and entry locks should never be held during blocking operations
// (such as dials or closing a net.Conn).
type clientConnPool struct {
	mu        sync.Mutex
	ents      map[connectMethodKey]*clientConnPoolEntry
	lru       intrusiveList[persistConn]
	closeIdle bool
}

The per-target state is one struct, and the interesting part is how it locks. Instead of a sync.Mutex plus condition variables, the entry uses three one-element channels as a hand-rolled lock/condition variable:

go
// src/net/http/transport_pool.go:44
type clientConnPoolEntry struct {
	key connectMethodKey

	// These three channels (each with a 1-element buffer) act as a lock/condition variable.
	//
	//   - if the entry is locked, no channel contains a value; otherwise
	//   - if idleConns contains a usable conn, cangetc contains a value; otherwise
	//   - if a new conn may be dialed, candialc contains a value; otherwise
	//   - lockc contains a value.
	//
	// Lock ordering: Only lock one entry at a time. Lock entries before the pool mutex.
	cangetc  chan struct{}
	candialc chan struct{}
	lockc    chan struct{}

	// Idle (available for use) connections, most recently used last.
	idleConns []*persistConn

	// Connection with alt != nil.
	//
	// This is historically a pseudo-"connection" backed by an http2.Transport,
	// which manages its own connection pool. Lack of visibility into this pool
	// causes a variety of bugs, such as connections-per-host tracking not accounting
	// for individual HTTP/2 connections.
	//
	// This may also be an HTTP/3 connection.
	//
	// TODO: Improve management of HTTP/2 and HTTP/3 connections.
	altConn *persistConn

	// Total connections, idle and otherwise.
	numConns int

	// Requests waiting for a connection.
	numWaiters int

	// Dials in progress.
	dials intrusiveList[poolDial]

	// Idle timer, fires when we should next check to see if conns
	// should be closed for idleness.
	idleTimer    *time.Timer
	idleTimerSet bool

	// Set when the entry is removed from its pool.
	removed bool
}

The lock protocol is the interesting part. A caller that wants a connection either takes one of the idle conns immediately, or parks on cangetc (a connection became available) or candialc (you may dial). Reading from any of those channels is the lock, and releasing the lock means pushing a token back into the appropriate channel:

go
// src/net/http/transport_pool.go:102
// lock unconditionally locks the entry.
func (ent *clientConnPoolEntry) lock() {
	select {
	case <-ent.lockc:
	case <-ent.cangetc:
	case <-ent.candialc:
	}
}

// unlock unlocks the entry.
//
// If the entry is no longer in use, unlock removes it from the pool.
// The pool mutex must not be held when calling unlock.
func (ent *clientConnPoolEntry) unlock(tr *Transport) {
	if !ent.removed && ent.numConns == 0 && ent.numWaiters == 0 && ent.dials.size == 0 && len(ent.idleConns) == 0 {
		// This entry is no longer in use; remove it from the pool.
		ent.removed = true
		if ent.idleTimer != nil {
			ent.idleTimer.Stop()
		}
		tr.pool.mu.Lock()
		delete(tr.pool.ents, ent.key)
		tr.pool.mu.Unlock()
	}
	switch {
	case len(ent.idleConns) > 0 || ent.altConn != nil:
		ent.cangetc <- struct{}{}
	case tr.MaxConnsPerHost <= 0 || ent.numConns+ent.dials.size < tr.MaxConnsPerHost:
		ent.candialc <- struct{}{}
	default:
		ent.lockc <- struct{}{}
	}
}

Read that unlock carefully: it is both an unlock and a scheduler. Whoever releases the entry decides what the next waiter is allowed to do — take an idle connection, or go dial a new one. If neither is possible (the host is at MaxConnsPerHost), the entry is simply locked and the next caller has to wait for someone else to release it. That is a condition variable expressed entirely in terms of channel handoffs, and it is why the comment at the top of the file says “Locking and unlocking entries is a channel operation, so we take some care to minimize the number of lock operations required in the hot path.”

The cross-host MaxIdleConns limit is an intrusive doubly-linked list rather than a container/list, because the list node lives inside the persistConn itself:

go
// src/net/http/intrusive_list.go:9
// intrusiveList is an intrusive doubly-linked list.
//
// The list is circular: head.prev is the tail of the list.
//
// The list's link function must be initialized to a function which returns the
// intrusiveLink structure for a list element. Elements may be a part of any number
// of lists at the same time, so long as they use separate links for each list.
type intrusiveList[T any] struct {
	head *T
	size int
	link func(*T) *intrusiveLink[T]
}

type intrusiveLink[T any] struct {
	prev, next *T
}

Putting a connection back into the idle list is where the per-host limit bites:

go
// src/net/http/transport_pool.go:284
func (t *Transport) poolPutIdleConnLocked(ent *clientConnPoolEntry, pconn *persistConn) (_ error, needExpire bool) {
	t.pool.mu.Lock()
	defer t.pool.mu.Unlock()

	if pconn.isBroken() || !pconn.isInPool {
		return errConnBroken, false
	}
	if t.pool.lru.contains(pconn) {
		// This conn is already idle.
		// Move it to the back of the idle list.
		t.pool.lru.remove(pconn)
	} else {
		// This conn is entering the idle list.
		if t.keepAlivesDisabled() {
			return errKeepAlivesDisabled, false
		}
		if len(ent.idleConns) >= t.maxIdleConnsPerHost() {
			return errTooManyIdleHost, false
		}
		if pconn.alt != nil && ent.altConn != nil {
			// We already have a reusable, shared connection;
			// discard this one.
			return errTooManyIdleHost, false
		}
		if t.pool.closeIdle {
			return errCloseIdle, false
		}
		if pconn.alt == nil {
			ent.idleConns = append(ent.idleConns, pconn)
		} else {
			ent.altConn = pconn
		}
	}

	if pconn.alt == nil {
		t.pool.lru.pushBack(pconn)
	}

	// Arm the idle timer (if needed).
	pconn.idleAt = time.Now()
	if t.IdleConnTimeout > 0 && !ent.idleTimerSet && pconn.alt == nil {
		if ent.idleTimer != nil {
			ent.idleTimer.Reset(t.IdleConnTimeout)
		} else {
			ent.idleTimer = time.AfterFunc(t.IdleConnTimeout, func() {
				t.poolIdleTimeout(ent)
			})
		}
		ent.idleTimerSet = true
	}
	needExpire = t.MaxIdleConns != 0 && t.pool.lru.size > t.MaxIdleConns
	return nil, needExpire
}

The errTooManyIdleHost return is the whole “two idle conns per host” rule: the third connection to finish is simply not put back, and the caller closes it.

The cross-host limit is enforced separately, by walking the LRU from the front and closing connections until the pool is back under MaxIdleConns:

go
// src/net/http/transport_pool.go:648
// expireIdleLRU removes idle conns from the pool until the number of idle conns
// is less than or equal to MaxIdleConns.
func (t *Transport) expireIdleLRU() {
	t.pool.mu.Lock()
	defer t.pool.mu.Unlock()
	if t.MaxIdleConns == 0 {
		return
	}
	maxIdle := max(t.MaxIdleConns, 0)
	for t.pool.lru.size > maxIdle {
		// Lock ordering is ent >> pool, so we need to drop the pool mutex
		// before locking the entry.
		ent := t.pool.lru.head.poolEnt
		t.pool.mu.Unlock()
		ent.lock()
		t.pool.mu.Lock()

		// Expire conns for the entry we currently have locked.
		// It's possible the head of the LRU changed while we were juggling locks;
		// that's okay. Worst case, we loop back and try again with the new head.
		for t.pool.lru.size > maxIdle {
			pconn := t.pool.lru.head
			if pconn.poolEnt != ent {
				break
			}
			if idx := slices.Index(ent.idleConns, pconn); idx >= 0 {
				ent.idleConns = slices.Delete(ent.idleConns, idx, idx+1)
				ent.unregisterConnLocked(pconn)
				go pconn.close(errTooManyIdle)
			}
			t.pool.lru.remove(pconn)
		}

		// ent.unlock can lock the pool (if the entry is now dead),
		// so we need to drop the pool mutex while unlocking it.
		t.pool.mu.Unlock()
		ent.unlock(t)
		t.pool.mu.Lock()
	}
}

Note the lock juggling: the pool mutex has to be dropped to take an entry lock, because the documented lock order is entry-then-pool, never the other way round. That dance is the price of the two-level structure.

Two goroutines per connection

Every pooled HTTP/1 connection runs two goroutines. They are started together, right after the connection is set up:

go
// src/net/http/transport.go:1542
			return &persistConn{
				t:        t,
				cacheKey: pconn.cacheKey,
				alt:      alt,
				poolEnt:  poolEnt,
			}, nil
		}
	}

	pconn.br = bufio.NewReaderSize(pconn, t.readBufferSize())
	pconn.bw = bufio.NewWriterSize(persistConnWriter{pconn}, t.writeBufferSize())

	go pconn.readLoop()
	go pconn.writeLoop()
	return pconn, nil
}

The persistConn struct is the shared state between them and the caller:

go
// src/net/http/transport.go:1663
// persistConn wraps a connection, usually a persistent one
// (but may be used for non-keep-alive requests as well)
type persistConn struct {
	// alt optionally specifies the TLS NextProto RoundTripper.
	// This is used for HTTP/2 today and future protocols later.
	// If it's non-nil, the rest of the fields are unused.
	alt RoundTripper

	// Guarded by clientConnPoolEntry's lock.
	poolEnt  *clientConnPoolEntry
	lruLink  intrusiveLink[persistConn]
	isInPool bool      // set when counted in pool entry's numConns
	idleAt   time.Time // time it last become idle

	t            *Transport
	cacheKey     connectMethodKey
	conn         net.Conn
	tlsState     *tls.ConnectionState
	br           *bufio.Reader       // from conn
	bw           *bufio.Writer       // to conn
	nwrite       int64               // bytes written
	reqch        chan requestAndChan // written by roundTrip; read by readLoop
	writech      chan writeRequest   // written by roundTrip; read by writeLoop
	closech      chan struct{}       // closed when conn closed
	availch      chan struct{}       // ClientConn only: contains a value when conn is usable
	isProxy      bool
	sawEOF       bool  // whether we've seen EOF from conn; owned by readLoop
	isClientConn bool  // whether this is a ClientConn (outside any pool)
	readLimit    int64 // bytes allowed to be read; owned by readLoop
	// writeErrCh passes the request write error (usually nil)
	// from the writeLoop goroutine to the readLoop which passes
	// it off to the res.Body reader, which then uses it to decide
	// whether or not a connection can be reused. Issue 7569.
	writeErrCh chan error

	writeLoopDone chan struct{} // closed when write loop ends

	mu                   sync.Mutex // guards following fields
	numExpectedResponses int
	closed               error  // set non-nil when conn is closed, before closech is closed
	canceledErr          error  // set non-nil if conn is canceled
	reused               bool   // whether conn has had successful request/response and is being reused.
	reserved             bool   // ClientConn only: concurrency slot reserved
	inFlight             bool   // ClientConn only: request is in flight
	internalStateHook    func() // ClientConn state hook

	// mutateHeaderFunc is an optional func to modify extra
	// headers on each outbound request before it's written. (the
	// original Request given to RoundTrip is not modified)
	mutateHeaderFunc func(Header)
}

The split is deliberate. The writeLoop owns the write half of the connection and does exactly one thing per request:

go
// src/net/http/transport.go:2247
func (pc *persistConn) writeLoop() {
	defer close(pc.writeLoopDone)
	for {
		select {
		case wr := <-pc.writech:
			startBytesWritten := pc.nwrite
			err := wr.req.Request.write(pc.bw, pc.isProxy, wr.req.extra, pc.waitForContinue(wr.continueCh))
			if bre, ok := err.(requestBodyReadError); ok {
				err = bre.error
				// Errors reading from the user's
				// Request.Body are high priority.
				// Set it here before sending on the
				// channels below or calling
				// pc.close() which tears down
				// connections and causes other
				// errors.
				wr.req.setError(err)
			}
			if err == nil {
				err = pc.bw.Flush()
			}
			if err != nil {
				if pc.nwrite == startBytesWritten {
					err = nothingWrittenError{err}
				}
			}
			pc.writeErrCh <- err // to the body reader, which might recycle us
			wr.ch <- err         // to the roundTrip function
			if err != nil {
				// The conn will be closed by either roundTrip or readLoop.
				return
			}
		case <-pc.closech:
			return
		}
	}
}

The readLoop owns the read half and is where the “is this connection reusable?” decision is made. It peeks one byte at the top of each iteration to notice a closed connection while it is idle:

go
// src/net/http/transport.go:1868
func (pc *persistConn) readLoop() {
	closeErr := errReadLoopExiting // default value, if not changed below
	defer func() {
		pc.close(closeErr)
		pc.t.removeIdleConn(pc)
		if pc.internalStateHook != nil {
			pc.internalStateHook()
		}
	}()

	tryPutIdleConn := func(treq *transportRequest) bool {
		trace := treq.trace
		if err := pc.t.tryPutIdleConn(pc); err != nil {
			closeErr = err
			if trace != nil && trace.PutIdleConn != nil && err != errKeepAlivesDisabled {
				trace.PutIdleConn(err)
			}
			return false
		}
		if trace != nil && trace.PutIdleConn != nil {
			trace.PutIdleConn(nil)
		}
		return true
	}

	// eofc is used to block caller goroutines reading from Response.Body
	// at EOF until this goroutines has (potentially) added the connection
	// back to the idle pool.
	eofc := make(chan struct{})
	defer close(eofc) // unblock reader on errors

The body lifecycle, which is where reuse is won and lost

A connection is only reusable if the response body has been fully consumed. This is the single most common reason a Go program ends up dialling new connections constantly: it reads the headers, gets distracted, and never closes the body.

The machinery is a bodyEOFSignal wrapper whose whole job is to tell the readLoop what happened to the body:

go
// src/net/http/transport.go:1991
		waitForBodyRead := make(chan bodyReadStatus, 1)
		body := &bodyEOFSignal{
			body: resp.Body,
			earlyCloseFn: func() error {
				waitForBodyRead <- bodyReadClosedEarly
				<-eofc // will be closed by deferred call at the end of the function
				return nil
			},
			fn: func(err error) error {
				if err == io.EOF {
					waitForBodyRead <- bodyReadEOF
					<-eofc // see comment above eofc declaration
				} else {
					waitForBodyRead <- bodyReadError
					if err != nil {
						if cerr := pc.canceled(); cerr != nil {
							return cerr
						}
					}
				}
				return err
			},
		}

		resp.Body = body

Three outcomes, and the readLoop reacts to each:

go
// src/net/http/transport.go:2041
			switch status {
			case bodyReadEOF:
				tryPutIdle()
				eofc <- struct{}{}
			case bodyReadClosedEarly:
				// Read resp before signaling eofc: the send lets the caller's
				// Close return, and resp belongs to the caller after that.
				tryDrain := alive && !pc.t.keepAlivesDisabled() && resp.ContentLength <= maxPostCloseReadBytes
				eofc <- struct{}{}
				if tryDrain && maybeDrainBody(body.body) {
					tryPutIdle()
				} else {
					alive = false
				}
			case bodyReadError:
				alive = false
			}
  • Read to EOF (bodyReadEOF): the connection goes back into the idle pool. This is the good path.
  • Closed early (bodyReadClosedEarly): the transport tries to rescue the connection by draining the remaining bytes, but only if the body was small, and only within a 50 millisecond budget:
go
// src/net/http/transport.go:1832
// maxPostCloseReadTime defines the maximum amount of time that a client is
// willing to spend on draining a response body of any unread bytes after it
// has been closed.
const maxPostCloseReadTime = 50 * time.Millisecond

func maybeDrainBody(r io.Reader) bool {
	drainedCh := make(chan bool, 1)
	go func() {
		// When we drain the body and (hopefully) reach EOF, we might
		// potentially need to deal with trailers. Make sure they are discarded
		// so the connection can actually be reused.
		if b, ok := r.(*body); ok {
			b.discardTrailer()
		}
		if _, err := io.CopyN(io.Discard, r, maxPostCloseReadBytes+1); err == io.EOF {
			drainedCh <- true
		} else {
			drainedCh <- false
		}
	}()
	select {
	case drained := <-drainedCh:
		return drained
	case <-time.After(maxPostCloseReadTime):
		return false
	}
}
  • Read error (bodyReadError): the connection is dead. alive = false, the readLoop returns, and its deferred pc.close(closeErr) tears the socket down.

The eofc channel is a subtle piece of plumbing: it exists so that a caller blocked in resp.Body.Read cannot observe EOF before the connection has been returned to the pool. Without it there would be a window where the caller sees EOF, immediately fires another request, and the pool is still empty — so it dials a second connection while the first is about to become idle.

Hooks: ConnState is on the server, httptrace is on the client

A common misconception: ConnState looks like it should be a client-side hook, telling you when a pooled connection is reused. It is not. ConnState belongs to http.Server:

go
// src/net/http/server.go:3509
// A ConnState represents the state of a client connection to a server.
// It's used by the optional [Server.ConnState] hook.
type ConnState int

const (
	// StateNew represents a new connection that is expected to
	// send a request immediately. Connections begin at this
	// state and then transition to either StateActive or
	// StateClosed.
	StateNew ConnState = iota

The client-side equivalent is httptrace.ClientTrace, and it has exactly the hooks you would want for watching the pool:

go
// src/net/http/httptrace/trace.go:79
type ClientTrace struct {
	// GetConn is called before a connection is created or
	// retrieved from an idle pool. The hostPort is the
	// "host:port" of the target or proxy. GetConn is called even
	// if there's already an idle cached connection available.
	GetConn func(hostPort string)

	// GotConn is called after a successful connection is
	// obtained. There is no hook for failure to obtain a
	// connection; instead, use the error from
	// Transport.RoundTrip.
	GotConn func(GotConnInfo)

	// PutIdleConn is called when the connection is returned to
	// the idle pool. If err is nil, the connection was
	// successfully returned to the idle pool. If err is non-nil,
	// it describes why not. PutIdleConn is not called if
	// connection reuse is disabled via Transport.DisableKeepAlives.
	// For HTTP/2, this hook is not currently used.
	PutIdleConn func(err error)

GotConnInfo even tells you whether the connection was reused and how long it sat idle. If you are wondering why your service re-dials all the time, this is the hook to install — count GotConn calls where Reused is false.

Trade-offs

What the pool buys: a warm TCP connection (and, for HTTPS, a warm TLS session) costs a round trip and a handshake to recreate. Reusing one makes a request dramatically cheaper, and the two-goroutine-per-connection design means a slow response body does not block other requests on the same host — each connection has its own reader.

What it costs:

  • Memory and file descriptors. Every live connection is two goroutines (a few KB of stack each) plus two 4 KB buffers. A thousand pooled connections is a few thousand goroutines. MaxIdleConnsPerHost is the knob that keeps that number sane.
  • The per-host limit of 2. Two idle conns is deliberately too small for a bursty client. Raising it trades file descriptors for fewer dials; leaving it alone means bursty workloads pay a dial per burst.
  • Channel-based locking. The entry lock is a channel handoff. That is fast enough in practice, but it does mean “lock” and “wait for a condition” are the same operation, and the code has to be careful about how many times it does it.
  • The idle timer is per entry. Each target gets a time.Timer armed when its first connection goes idle, rather than one global sweep.

Caveats

Closing the body is not optional. If you stop reading a response body without closing it, the connection is not returned to the pool and will eventually be closed by the server. defer resp.Body.Close() is the minimum; reading to EOF is what actually keeps the connection.

MaxIdleConnsPerHost is not MaxConnsPerHost. The first limits idle connections; the second limits total connections including in-flight ones. Setting only the second does not stop the pool from holding many sockets open in the idle state, and setting only the first does not stop a burst from opening fifty sockets.

HTTP/2 and HTTP/3 connections are not really pooled. An altConn is a pseudo-connection standing in for an http2.Transport that manages its own pool. The Transport code says so, and flags it as a source of bugs:

go
// src/net/http/transport_pool.go:62
	// Connection with alt != nil.
	//
	// This is historically a pseudo-"connection" backed by an http2.Transport,
	// which manages its own connection pool. Lack of visibility into this pool
	// causes a variety of bugs, such as connections-per-host tracking not accounting
	// for individual HTTP/2 connections.

MaxIdleConns is global. With MaxIdleConns: 100 and MaxIdleConnsPerHost: 2, a service talking to 200 hosts can only keep 100 idle connections in total; the LRU evicts the least recently used across all hosts, so a busy host can evict a quiet one.

The rewrite is new. The commit message for the pool rewrite says “For now, the new pool behaves mostly identically to the old one.” Mostly. Behavioural edge cases in HTTP/1 pooling are exactly the kind of thing that differs in the corners, and the transport_test.go churn in that commit is substantial.

Wrapping up

The one thing to remember: Transport keeps two idle connections per host by default, and a connection is only reusable once its response body has been read to EOF. Everything else in the pool — the LRU, the idle timer, the per-host limit — is secondary to those two rules.

The part people get wrong: believing that ConnState tells you about client-side connection reuse. It is a server hook. To find out why your client is re-dialling, install an httptrace.ClientTrace and count GotConn calls with Reused == false.

What to go do about it: run a request against a test server with httptrace enabled and watch GetConn/GotConn/PutIdleConn fire. If PutIdleConn is reporting an error, or GotConn is never reporting a reused connection, the body lifecycle is where the bug is. Then set MaxIdleConnsPerHost deliberately rather than accepting two.

Further reading

  • src/net/http/transport.go — the Transport struct, persistConn, readLoop, writeLoop, and the body-EOF signalling.
  • src/net/http/transport_pool.go — the rewritten pool: clientConnPool, clientConnPoolEntry, poolGetConn, poolPutIdleConnLocked, expireIdleLRU.
  • src/net/http/intrusive_list.go — the intrusive LRU list used for the cross-host idle connection limit.
  • src/net/http/httptrace/trace.go — the client-side hooks: GetConn, GotConn, PutIdleConn.
  • src/net/http/server.go — the server-side ConnState hook, for contrast.
  • src/net/http/client.go — the Client wrapper: redirects, cookies, timeouts.
  • src/net/http/transport_test.go — the behavioural contract the pool is expected to keep.

GoInternalsNet HTTPPerformanceConcurrency