The handshake tax
You build a service that fetches data from an upstream API. Locally, it feels instant. You deploy it, traffic arrives, and latency spikes. The CPU is idle. The database is quiet. The bottleneck is the network stack. Every request triggers a new TCP connection. The client performs a three-way handshake, negotiates TLS, sends headers, gets a response, and tears the connection down. Under load, your application spends more time shaking hands than processing data.
Connection pooling eliminates this overhead. Instead of creating a fresh connection for every request, the client keeps a set of connections alive. When a new request arrives, the client grabs an idle connection from the pool, reuses it, and puts it back when the response is done. The handshake happens once. The data flows continuously.
Go handles this automatically for HTTP via the http.Transport type. The standard library maintains a pool of idle connections behind the scenes. You rarely need to write a pool from scratch. You need to understand how to tune it and how to avoid the traps that drain it.
How the pool works
Think of a connection pool like a station of rental bikes. Instead of ordering a new bike from the factory every time you want to ride, you grab one that is already parked at the station. When you finish, you return it. If someone else wants a bike, they take yours. The station keeps a fixed number of bikes ready. If the station is full, the next rider has to wait or the station might order more, depending on the rules.
In Go, the bikes are TCP connections. The station is the http.Transport. The transport tracks connections per host. When you make a request to https://api.example.com, the transport checks if there is an idle connection to that host. If yes, it reuses it. If no, it opens a new one. When the response body closes, the connection returns to the idle pool. A timer starts. If the connection sits idle longer than the timeout, the transport closes it to free resources.
The transport manages two limits. MaxIdleConns is the total number of idle connections the pool can hold across all hosts. MaxIdleConnsPerHost is the maximum number of idle connections kept for a single host. The effective limit for a host is the smaller of the per-host setting and the remaining capacity in the global bucket. If you set MaxIdleConns to 10 and MaxIdleConnsPerHost to 100, you can only keep 10 connections alive total, even if you hit one host. The global cap wins.
Configuring the transport
The default http.Client uses a default transport with sensible settings. The defaults keep 100 idle connections total and 2 per host. For many applications, this works fine. When you are hammering a single backend service, the per-host limit becomes a bottleneck. You create new connections constantly because the pool discards them after two.
Tune the transport by creating a custom http.Transport and assigning it to the client.
package main
import (
"fmt"
"net/http"
"time"
)
func main() {
// Create a transport with tuned pool settings.
// MaxIdleConns sets the global cap for all hosts.
// MaxIdleConnsPerHost sets the cap per host.
// IdleConnTimeout controls how long idle connections survive.
transport := &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 50,
IdleConnTimeout: 90 * time.Second,
}
// Assign the transport to the client.
// The client now reuses connections according to these rules.
client := &http.Client{
Transport: transport,
Timeout: 30 * time.Second,
}
resp, err := client.Get("https://api.example.com/data")
if err != nil {
panic(err)
}
// Close the body to return the connection to the pool.
resp.Body.Close()
fmt.Println("Request done. Connection returned to pool.")
}
The IdleConnTimeout matters. If you set it too high, you hold onto connections that the server might have already closed. If you set it too low, you churn connections and pay the handshake tax again. A common pattern is to set the client timeout slightly lower than the server timeout. If the server closes idle connections after 60 seconds, set IdleConnTimeout to 55 seconds. This ensures the client discards the connection before the server does. If the client tries to reuse a connection the server already closed, the request fails with a connection reset error.
The body close trap
The pool relies on you closing the response body. The transport hooks into the Close method to detect when the request is finished. When you call resp.Body.Close(), the transport checks if the connection is still valid. If yes, it returns the connection to the idle pool. If no, it discards it.
If you forget to close the body, the connection never returns to the pool. The transport thinks the connection is still in use. The pool fills up with "in-use" connections that are actually dead. New requests block waiting for an available connection. Eventually, the client times out.
The compiler does not warn you about unclosed bodies. This is a runtime leak. The convention is to always close the body immediately after creating the response, usually with defer.
func fetchData(client *http.Client, url string) ([]byte, error) {
resp, err := client.Get(url)
if err != nil {
return nil, err
}
// Defer close ensures the connection returns to the pool.
// This runs even if the read below fails.
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
The defer guarantees the body closes when the function returns. This protects the pool even if you hit an error while reading. If you read the body and panic, the defer still runs. The connection goes back to the pool. The pool stays healthy.
Realistic usage
In production code, you rarely create a client inside a function. You create it once at startup and pass it around. This ensures all requests share the same pool. Creating a new client for every request defeats the purpose of pooling. Each new client gets a new transport, which gets a new pool.
Pass the client as a dependency. This follows the convention of injecting configuration rather than relying on globals. It also makes testing easier. You can swap the client for a mock or a client with a custom transport.
type Service struct {
client *http.Client
baseURL string
}
// NewService creates a service with a tuned HTTP client.
// The client is shared across all method calls.
func NewService(baseURL string) *Service {
return &Service{
client: &http.Client{
Transport: &http.Transport{
MaxIdleConns: 200,
MaxIdleConnsPerHost: 100,
IdleConnTimeout: 60 * time.Second,
},
Timeout: 15 * time.Second,
},
baseURL: baseURL,
}
}
// GetUser fetches a user by ID.
// Context is passed as the first parameter for cancellation.
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.baseURL+"/users/"+id, nil)
if err != nil {
return nil, err
}
resp, err := s.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}
The context.Context goes as the first parameter. This is a Go convention. Functions that perform I/O should accept a context so callers can cancel long-running operations. The http.Client respects context cancellation. If the context expires, the request aborts. The transport cleans up the connection. The pool remains consistent.
Pitfalls and errors
Connection pooling introduces subtle failure modes. Understanding these helps you diagnose issues quickly.
The most common error is context deadline exceeded. This happens when the pool is exhausted. All connections are in use or idle but blocked by limits. New requests wait for a connection. If the wait exceeds the client timeout or context deadline, the request fails. The error message does not mention the pool. It just says the deadline passed. Check your MaxIdleConnsPerHost and MaxIdleConns. If you are hitting a single host hard, increase MaxIdleConnsPerHost. If you are hitting many hosts, increase MaxIdleConns.
Another error is connection reset by peer. This occurs when the client tries to reuse a connection that the server has already closed. The server closes idle connections based on its own timeout. If the client timeout is longer than the server timeout, the client holds a "zombie" connection. The next request sends data to a closed socket. The server resets the connection. The client detects the error, discards the connection, and retries. This adds latency. Tune IdleConnTimeout to be shorter than the server timeout.
You might see too many open files from the OS. This happens when the pool grows too large or connections leak. Each connection consumes a file descriptor. If you forget to close bodies, connections accumulate. The OS runs out of descriptors. The application crashes. Always close bodies. Monitor file descriptor usage in production.
Disabling keep-alives is rarely the right move. Setting DisableKeepAlives to true forces the client to close the connection after every request. This destroys the pool. Use this only when debugging a proxy that breaks on persistent connections or when talking to a legacy server that mishandles keep-alive headers. In normal operation, keep-alives are essential for performance.
The global client trap is a design pitfall. http.Get uses http.DefaultClient. This is a singleton shared across the entire process. If you write a library that uses http.Get, you lock the caller into global settings. The caller cannot tune the pool. The caller cannot set a timeout. The caller cannot inject a custom transport. Always create a client and pass it. Never use http.Get in library code.
Decision matrix
Connection pooling is automatic, but configuration requires choices. Use the right setting for your traffic pattern.
Use the default http.Client when you are making requests to many different domains and the standard limits work fine.
Use a custom http.Transport with higher MaxIdleConnsPerHost when your application sends a high volume of requests to a single upstream service.
Use DisableKeepAlives set to true when you are troubleshooting a flaky proxy or a legacy server that mishandles persistent connections.
Use a database connection pool via database/sql when you are connecting to a database and need transaction management alongside connection reuse.
Use a connection pool at all when the cost of establishing a connection is significant compared to the payload size.
Close the body. Always. The pool depends on it.