How do http.Transport's MaxIdleConnsPerHost and MaxConnsPerHost decide how many sockets a client holds?
answer
- two different ceilings, not one
- the zero value means opposite things in each
- the default idle-per-host number is very small
- total connections versus retained-for-reuse connections
- the pool lives in the transport, not the client
basics
~20 sMaxConnsPerHost caps the total connections to one host, including in-flight ones, and defaults to 0, meaning unlimited. MaxIdleConnsPerHost caps only how many finished connections are kept for reuse, and defaults to 2; surplus ones are closed rather than pooled.
solid answer
~40 sThey cap different things. `MaxConnsPerHost` bounds the *total* connections to a host — dialling, active and idle — and is 0 by default, meaning unlimited, so peak socket count tracks peak concurrency with no ceiling. `MaxIdleConnsPerHost` bounds only the *idle* connections kept for reuse, and defaults to `http.DefaultMaxIdleConnsPerHost`, which is 2; once a request finishes and the per-host idle slots are full, that connection is closed instead of pooled, so the next request dials a fresh one. `MaxIdleConns` (100 in `http.DefaultTransport`) is the same cap across all hosts, and `IdleConnTimeout` (90 seconds there) evicts idle connections that go unused. For a service fanning out to a few upstreams, raising `MaxIdleConnsPerHost` restores reuse and setting `MaxConnsPerHost` gives you a hard descriptor ceiling: requests queue instead of dialling forever.
code
go · 8 linesvar upstream = &http.Client{
Transport: &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 32, // zero would mean 2
MaxConnsPerHost: 64, // zero would mean unlimited
IdleConnTimeout: 90 * time.Second,
},
}go deeper
Know that Go's HTTP client reuses connections behind the scenes and that the reuse settings live on the transport, not on the client. Remember to share one client rather than building one per call.
Be able to name the four transport fields, state which zero values mean unlimited and which fall back to a small default, and explain why a full idle pool causes churn rather than accumulation.
Derive the numbers from measured per-upstream concurrency, and multiply across upstreams to check the total against the descriptor limit the process really has. Explain how HTTP/2 multiplexing changes the arithmetic.
Decide whether a per-upstream connection ceiling is a service-owner default across the fleet, and own the consequence: capping turns a descriptor outage into queueing latency that someone must be prepared to see on their dashboards.
## The transport is the pool In Go, an `http.Client` is a thin policy wrapper; the connection pool lives in its `http.Transport`. A transport keeps finished keep-alive connections in an **idle connection pool**, keyed by scheme, host and proxy, and hands one back to the next request for the same key. That reuse is why an HTTP client in a hot loop does not pay a TCP handshake and a TLS handshake per request — and it is also why the number of sockets a Go service holds is a property of the transport's configuration, not of your request code. Four fields shape it. ### MaxConnsPerHost — the total ceiling `MaxConnsPerHost` limits **all** connections to one host: those being dialled, those actively carrying a request, and those sitting idle. **The zero value means no limit.** That default is the one that matters for descriptor exhaustion: with no ceiling, if 5,000 requests to one upstream are in flight at once, the transport opens 5,000 sockets. Set it, and a request that finds every connection busy blocks until one frees up instead of dialling a new one. That converts a descriptor problem into a latency problem, which is nearly always the trade you want — but note that blocked requests wait on the connection, so the request context or client deadline is what stops them waiting forever. ### MaxIdleConnsPerHost — the reuse ceiling `MaxIdleConnsPerHost` limits only how many *finished* connections to one host are retained for reuse. Its zero value falls back to the constant `http.DefaultMaxIdleConnsPerHost`, which is **2**. This surprises people: a service hammering a single upstream at 200 concurrent requests keeps only two connections and closes the other 198 as each response completes, then dials fresh ones for the next batch. The important nuance is what that costs. A connection closed because the idle pool is full does **release** its descriptor, so this alone is churn rather than accumulation: handshake CPU, latency, and a pile of sockets in the kernel's `TIME_WAIT` state, which can exhaust ephemeral ports before it exhausts descriptors. But churn and an unbounded `MaxConnsPerHost` compound: with no reuse, live connection count rides concurrency exactly, and a slow upstream stretches how long each one lives. That is when the descriptor count goes vertical. ### MaxIdleConns — the same cap across all hosts `MaxIdleConns` is the global idle budget, 100 in `http.DefaultTransport`. It matters for a service that talks to many hosts: per-host slots are useless if the global pool evicts them. ### IdleConnTimeout — how long idle ones live `IdleConnTimeout` (90 seconds in `http.DefaultTransport`) closes a pooled connection that has gone unused for that long. Lowering it returns descriptors sooner and reduces the chance of using a connection the peer has already dropped; raising it improves reuse for bursty traffic. Note this is the *idle-connection* lifetime and has nothing to do with how long a request may take. ## The anti-pattern that defeats all four ```go func fetch(url string) (*http.Response, error) { client := &http.Client{Transport: &http.Transport{}} // new pool every call return client.Get(url) } ``` Because the pool lives in the transport, building a new transport per request gives every request a private pool of one, which is discarded immediately afterwards. Nothing is ever reused, and both descriptor count and handshake cost scale with request rate. `http.Client` and `http.Transport` are safe for concurrent use by design: create one per upstream (or one for the process) at startup and share it. If you must discard a transport, call its `CloseIdleConnections` so its pooled sockets are released rather than waiting for the idle timeout. The same reasoning applies to `DisableKeepAlives: true`, which tells the transport to close every connection after a single request. It is occasionally right for a one-shot CLI, and it is the worst possible setting for a long-lived service. ## HTTP/2 changes the shape Over HTTP/2 a single connection multiplexes many concurrent streams, so one socket per host can serve hundreds of requests and the per-host connection caps mostly stop being the binding constraint. When `http.DefaultTransport` negotiates HTTP/2 to an upstream that supports it, descriptor pressure on that leg largely disappears; when the upstream is HTTP/1.1, you are back to one connection per in-flight request. That is why the same client code can be perfectly healthy against one upstream and exhaust descriptors against another. ## How to pick numbers Start from the concurrency you actually run per upstream. Set `MaxIdleConnsPerHost` at or slightly above the steady-state concurrency so ordinary traffic is served entirely from the pool; set `MaxIdleConns` to the sum across upstreams; set `MaxConnsPerHost` to the largest number of simultaneous connections that upstream should ever see from one instance, which is both a descriptor ceiling for you and a politeness bound for them. Then multiply by the number of upstreams and check the total against the descriptor limit the process actually has.
- What actually happens to a request when MaxConnsPerHost is reached?It blocks inside the transport waiting for a connection to that host to become free, rather than dialling a new one. Nothing fails by itself, so the request's context deadline or the client's timeout is what eventually releases it. That is the trade: you convert descriptor exhaustion into queueing latency, which is visible and survivable.
- Why does creating an http.Client with a fresh http.Transport inside each request handler break reuse?The idle connection pool belongs to the transport. A new transport starts with an empty pool and is thrown away after the request, so every call dials, handshakes and closes. Both descriptor count and CPU scale with request rate. Build one client per upstream at startup and share it — both types are safe for concurrent use.
- Does raising MaxIdleConnsPerHost risk holding sockets you no longer need?Yes, and IdleConnTimeout is the control for it — 90 seconds in http.DefaultTransport. Idle connections cost a descriptor each on both ends and can be dropped by intermediaries without you knowing. Size the idle pool to steady-state concurrency rather than to peak, and let the timeout shed the rest.
saying these in an interview costs you the question
- Thinks MaxConnsPerHost defaults to a safe finite number
- Believes MaxIdleConnsPerHost limits concurrent requests
- Creates a new http.Client and Transport per request
- Assumes the connection pool lives on http.Client
- Confuses IdleConnTimeout with a per-request timeout