Why does creating a new *http.Client for each request in Go stop connection reuse?
answer
- Which of the two types owns sockets?
- The pool is per-Transport, not per-request
- Both types are safe for concurrent goroutines
- A discarded Transport takes its connections along
- One long-lived client per downstream dependency
basics
~20 sConnection pooling lives in the client's Transport, not in the request. A fresh http.Client per call gets an empty pool, so every request dials a new TCP and TLS connection and the one it leaves behind is never reused.
solid answer
~40 sIn `net/http` the keep-alive pool of idle connections is held by the `*http.Transport` inside an `*http.Client`. An `http.Client` value with no `Transport` set uses the shared `http.DefaultTransport`, but if you build your own `&http.Client{Transport: &http.Transport{}}` per call, each one owns a private, empty pool: the connection it opens is dropped when the client becomes garbage, so you pay a TCP handshake (and a TLS handshake) on every request and leave sockets in `TIME_WAIT` on the client host. `*http.Client` is safe for concurrent use by many goroutines, so the idiom is one long-lived client per dependency, created once and stored in package or struct state, each with its own timeout and transport tuning.
code
go · 7 linesvar billing = newBillingClient()
func newBillingClient() *http.Client {
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConnsPerHost = 64
return &http.Client{Transport: t, Timeout: 5 * time.Second}
}go deeper
Be ready to say that the connection pool lives in the Transport inside an http.Client, and that you build one client per downstream service and keep it for the life of the process.
Explain the mechanics: a nil Transport falls back to http.DefaultTransport, a hand-built Transport gets a private empty pool, and both types are safe for concurrent use so sharing is the point, not a compromise.
Show how you would catch this in review or in production: transports constructed inside handlers, handshake latency on every call, TIME_WAIT growth on the client host, and httptrace's Reused flag as proof.
Own the convention across services: clients constructed in constructors, one per dependency with its own timeout and pool sizing, and a standing rule that no library mutates http.DefaultTransport.
## What an `http.Client` actually is An `http.Client` is a thin, mostly stateless policy object. Its fields are `Transport` (how a single request/response exchange is carried out), `CheckRedirect`, `Jar` (cookies) and `Timeout` (a deadline over the whole exchange). It holds no sockets of its own. All of the machinery people mean when they say "the HTTP connection pool" — dialing, TLS setup, keep-alive bookkeeping, the list of idle connections per destination — lives in the `*http.Transport` that `Client.Transport` points at. If `Client.Transport` is nil, the client uses the package-level `http.DefaultTransport`. That is why `http.Get` and `http.DefaultClient` do get pooling for free: they all funnel through one shared transport value. ## Why a per-request client kills reuse A transport can only reuse a connection it still remembers. Its idle list is per-transport and keyed by destination (scheme, host and port, plus proxy). So: ```go func fetch(url string) (*http.Response, error) { c := &http.Client{Transport: &http.Transport{}} // new, empty pool return c.Get(url) } ``` Every call constructs a transport whose idle list is empty, dials a brand-new TCP connection, completes the exchange, files the connection in *its own* idle list, and then loses the whole transport to the garbage collector. Nothing that comes later can find that connection. The measurable effects are: - **Handshake cost on every call.** One TCP round trip, plus a full TLS handshake for HTTPS. On a cross-region hop that can dominate the request's latency. - **Socket churn.** The client is usually the side that closes, so each finished connection sits in `TIME_WAIT` on the client host for roughly two maximum segment lifetimes. At a few thousand requests per second to one host you can exhaust the local ephemeral port range and start seeing dial failures. - **Wasted file descriptors and goroutines.** Each live connection has read/write goroutines behind it; abandoned transports keep them alive until their connections are closed or time out. Note the subtler variant: `&http.Client{}` per request with **no** `Transport` set is far less harmful, because all those clients share `http.DefaultTransport` and therefore share one pool. The expensive mistake is a new *transport* per request. Because the two are usually written together, the safe rule to remember is "one client per dependency, built once". ## Concurrency `*http.Client` and `*http.Transport` are explicitly documented as safe for concurrent use by multiple goroutines, and they are designed to be shared. There is no reason to give each request, each handler, or each goroutine its own. Sharing is not a compromise — it is how the pool gets its hit rate. ## One client per dependency The usual shape is a package-level or struct-level client per downstream service: ```go type BillingClient struct{ http *http.Client } func NewBillingClient() *BillingClient { t := http.DefaultTransport.(*http.Transport).Clone() t.MaxIdleConnsPerHost = 64 return &BillingClient{http: &http.Client{Transport: t, Timeout: 5 * time.Second}} } ``` Per *dependency*, not one global for everything, because each downstream deserves its own timeout and its own pool sizing, and because a slow dependency should not be able to soak up connection slots that another one needs. `Transport.Clone()` is the idiomatic way to start from `http.DefaultTransport`'s settings (proxy from the environment, HTTP/2 attempt, 90-second idle timeout) and change only what you mean to change. What you should **not** do is mutate `http.DefaultTransport`'s fields directly: it is process-global, shared with every other package in the binary, including libraries you did not write. ## Lifecycle A long-lived client needs no shutdown in most services — idle connections expire on their own. If you really are done with a client (a CLI finishing up, a test tearing down), `Client.CloseIdleConnections()` releases the pooled sockets immediately. Connections still in flight are unaffected. ## How to check it in review Grep for `http.Client{` and `http.Transport{` inside functions that run per request. Any transport constructed below `main`/`init`/a constructor is suspicious. In a running process, `net/http/httptrace`'s `GotConn` hook reports `Reused` and `WasIdle` for each request, which turns "I think we're pooling" into a measurement.
- Is creating `&http.Client{}` per request, with no Transport field set, as bad as creating a new Transport per request?No. A client with a nil `Transport` falls back to the shared `http.DefaultTransport`, so all of those clients share one pool and connections are still reused. You lose only per-dependency configuration. The expensive mistake is constructing a new `*http.Transport`, because that is the value that owns the idle connections.
- Can several `*http.Client` values share one `*http.Transport`?Yes, and it is a common pattern. `*http.Transport` is safe for concurrent use, so you can hand the same transport to clients that differ in `Timeout`, `CheckRedirect` or `Jar` and they will all draw from the same connection pool. The tradeoff is that they then also share pool limits, so one caller's burst can crowd out another's.
- Why is mutating `http.DefaultTransport`'s fields discouraged in a library?It is a process-global value used by `http.DefaultClient`, `http.Get` and every package in the binary that did not set its own transport. Raising or lowering its limits silently changes behaviour for code you do not own. Use `http.DefaultTransport.(*http.Transport).Clone()` and configure the copy instead.
- Does a long-lived client need an explicit shutdown?Usually not: idle connections are evicted by `Transport.IdleConnTimeout` and the process exits with them. When you genuinely want the sockets back — a finished CLI run, a test's cleanup, a config reload that replaces the client — call `Client.CloseIdleConnections()`, which drops pooled connections without disturbing in-flight requests.
The client is the order form; the transport is the delivery van with its parked fleet. Printing a new order form is cheap, but buying a new van for each parcel and abandoning it at the kerb is not.
saying these in an interview costs you the question
- Thinks http.Client holds the connections rather than http.Transport
- Claims http.Client is not safe for concurrent use, so builds one per goroutine
- Believes every http.Client automatically shares one global pool
- Constructs &http.Transport{} inside a request handler
- Mutates http.DefaultTransport's fields from a library's init function
- Assumes the garbage collector recycles the pooled connections for reuse