27.4 http.Transport: Connection Pooling, Keep-Alives, and TLS Config
Right, let’s talk about the engine room of the Go HTTP client: http.Transport. This is the struct that actually does the heavy lifting of managing your connections, dealing with TCP handshakes, TLS negotiations, and all the other gnarly network stuff so you don’t have to. If http.Client is the driver, http.Transport is the entire car—engine, transmission, and all.
By default, the client you get from http.DefaultClient uses a perfectly serviceable http.DefaultTransport. But the moment you need to do anything interesting—like tweak timeouts, accept self-signed certificates for a dev environment, or bypass a proxy—you’ll need to understand and configure your own.
The Connection Pool and Keep-Alives
Here’s the first bit of good news: the default transport is brilliantly efficient because it maintains a pool of idle keep-alive connections. When you make a request to https://api.example.com, it opens a TCP connection, does the TLS handshake (which is expensive), and sends the request. Instead of closing the connection afterward like a barbarian, it keeps it open and places it in an idle pool.
The next request you make to the same host gets to reuse that existing, warm connection. This saves you the cost of the TCP three-way handshake and the TLS handshake every single time. It’s a massive performance win. The pool is per-host, so connections to api.example.com won’t be reused for requests to images.example.com.
You can see this in action. The default behavior is so effective you often don’t think about it.
// This first request incurs the full cost of connection setup.
resp1, err := http.Get("https://httpbin.org/get")
if err != nil {
panic(err)
}
defer resp1.Body.Close()
// Read the body to completion so the connection can be reused!
io.Copy(io.Discard, resp1.Body)
// This second request to the same host will almost certainly
// reuse the idle keep-alive connection. Much faster.
resp2, err := http.Get("https://httpbin.org/json")
if err != nil {
panic(err)
}
defer resp2.Body.Close()
io.Copy(io.Discard, resp2.Body)
The critical best practice here, which the code above demonstrates, is to always read the response body to completion and close it. If you don’t, the transport can’t reclaim the connection for the pool, and you leak resources. It’s the most common rookie mistake. Use defer resp.Body.Close() and io.Copy(io.Discard, resp.Body) if you don’t actually care about the content.
Configuring Timeouts and Limits
The default timeouts on http.DefaultTransport are… optimistic. They’re designed for a world where networks are perfect. We don’t live in that world. You must set your own timeouts. The http.Transport has several, and they’re subtle but important:
DialContext: Timeout for establishing the TCP connection.TLSHandshakeTimeout: Timeout for the TLS handshake after the TCP connection is made.ResponseHeaderTimeout: Timeout for the server to send the response headers after your request is fully written.IdleConnTimeout: How long an idle connection is kept in the pool before being closed.
Here’s how you build a robust transport for a production client:
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10, // This is the key one for performance
}
client := &http.Client{
Transport: transport,
Timeout: 60 * time.Second, // This is a TOTAL timeout for the entire request.
}
Note the difference: the http.Client.Timeout is a nuclear option. It covers everything from start to finish: dial, TLS, request write, response read. The transport timeouts are more granular, giving you finer control over each phase. You should set both.
Taming TLS
This is where you often have to fight the defaults. The default transport is configured for maximum security, which is great for hitting public APIs. It’s less great when your internal company API uses a self-signed certificate or a certificate from a private CA.
The security purists will tell you to never do this. The engineers who have to deploy to staging say otherwise. Here’s how you make your client play nice with your internal CA or, if you’re truly desperate, skip verification altogether.
Option 1: Trust a Custom CA (The Right Way)
caCertPool := x509.SystemCertPool() // Copy the system pool
if caCertPool == nil {
caCertPool = x509.NewCertPool()
}
// Read in your company's CA cert
pem, err := os.ReadFile("/path/to/your-company-ca.pem")
if err != nil {
panic(err)
}
if !caCertPool.AppendCertsFromPEM(pem) {
panic("failed to append CA cert")
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caCertPool,
},
}
Option 2: Skip Verification (The “It’s Fine, We’re in Dev, I Promise” Way)
transport := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true, // You are now officially a warning in a security scan.
},
}
Use Option 2 only for prototyping or against machines that have no possible connection to the real internet. It completely nullifies the point of TLS. But sometimes, you just need to get past a badly configured dev server.
The http.Transport is the key to building a client that is efficient, resilient, and adaptable to your network environment. Tune it. Understand it. Your applications will be faster and more reliable for it.