No history yet

Custom Transport Optimization

Tuning Connection Pools

For high-throughput microservices, the default http.Client is a performance trap. Its reliance on http.DefaultTransport means you're sharing a connection pool with no specific tuning, leading to unnecessary latency from constant TCP and TLS handshakes. Creating a custom http.Transport is essential for controlling connection reuse.

The two most critical knobs are MaxIdleConns and MaxIdleConnsPerHost. MaxIdleConns sets the total number of idle connections across all hosts. Think of it as the total capacity of your connection parking garage. MaxIdleConnsPerHost, on the other hand, reserves a number of those spots for each specific host. The default is just two, which is far too low for any serious API client that repeatedly calls the same downstream service.

If a client communicates with many different services, a large MaxIdleConns is necessary. However, if it primarily communicates with a few specific services, increasing MaxIdleConnsPerHost is the key. Setting MaxIdleConnsPerHost equal to MaxIdleConns is a common pattern when your client only talks to one or a handful of other services, effectively dedicating the entire pool to them. Misconfiguring these can lead to port exhaustion or, conversely, underutilization of established connections, forcing expensive handshakes on new requests.

package main

import (
	"net/http"
	"time"
)

func createOptimizedTransport() *http.Transport {
	return &http.Transport{
		// Total idle connections across all hosts.
		MaxIdleConns: 100,
		// Idle connections to a single host. Must be <= MaxIdleConns.
		MaxIdleConnsPerHost: 100,
		// Timeout for idle connections before they are closed.
		IdleConnTimeout: 90 * time.Second,
		// Total time to establish a connection, including DNS, TCP, and TLS.
		TLSHandshakeTimeout: 10 * time.Second,
		// Time to wait for a server's first response headers.
		ResponseHeaderTimeout: 10 * time.Second,
		// Time to wait for the next data block from the server.
		ExpectContinueTimeout: 1 * time.Second,
	}
}

func main() {
	transport := createOptimizedTransport()
	client := &http.Client{Transport: transport}

	// Use this client for all outgoing requests.
	_, _ = client.Get("https://api.example.com/data")
}

The IdleConnTimeout dictates how long an unused connection can remain in the pool before being closed. This value must be less than the server's keep-alive timeout. If the server closes a connection that the client thinks is still open, your application will receive an unexpected EOF error on the next request that tries to use it. A value around 90 seconds is often a safe starting point, but it should be coordinated with the infrastructure you're communicating with.

Customizing Transport Behavior

Tuning pool sizes is just the beginning. The real power comes from understanding that http.Transport is an implementation of the http.RoundTripper interface. This interface is the heart of the Go HTTP client, defining a single method: RoundTrip(*http.Request) (*http.Response, error). By implementing your own , you can inject custom logic directly into the request lifecycle.

This pattern is incredibly powerful for implementing cross-cutting concerns. You can wrap the default http.Transport to add functionality like circuit breaking, request signing, or detailed metrics collection. For example, a metrics wrapper could start a timer before calling the underlying RoundTrip method and record the duration after it returns. This isolates transport-level concerns from your application logic, keeping your business code clean.

package main

import (
	"log"
	"net/http"
	"time"
)

// metricsWrapper adds logging and timing to a RoundTripper.	ype metricsWrapper struct {
	next http.RoundTripper
}

// RoundTrip executes a single HTTP transaction, adding metrics.
func (mw *metricsWrapper) RoundTrip(req *http.Request) (*http.Response, error) {
	start := time.Now()

	// Pass the request to the next RoundTripper in the chain (e.g., http.Transport).
	resp, err := mw.next.RoundTrip(req)

	duration := time.Since(start)
	log.Printf("Request to %s took %v", req.URL, duration)

	return resp, err
}

func main() {
	// Start with a base transport.
	baseTransport := &http.Transport{}

	// Wrap it with our custom logic.
	metricsTransport := &metricsWrapper{next: baseTransport}

	client := &http.Client{
		Transport: metricsTransport,
	}

	// Every request made with this client will now be timed.
	_, _ = client.Get("https://api.example.com")
}

TLS and HTTP/2 Nuances

For secure services, TLS configuration is another optimization vector. You can provide a custom tls.Config to the http.Transport. One key optimization is enabling session resumption via tickets or client-side caches. This allows a client reconnecting to a server to bypass the full TLS handshake, significantly reducing latency. You can achieve this by implementing a custom GetClientSession and PutClientSession on a tls.Config instance and assigning it to the transport.

Finally, remember that Go's http.Transport automatically upgrades connections to HTTP/2 when the server supports it. HTTP/2's key feature is , which allows multiple requests and responses to be in flight over a single TCP connection simultaneously. This eliminates the head-of-line blocking problem of HTTP/1.1 and often removes the need for clients to open many parallel connections to the same host.

However, this means your MaxIdleConnsPerHost setting has a different implication. With HTTP/2, a single connection can handle concurrent requests, so you may not need a large pool of idle connections. Often, a MaxIdleConnsPerHost of 1 is sufficient, as that one connection can serve many ongoing requests. The key is that IdleConnTimeout still matters for closing the connection after a period of true inactivity.

For HTTP/2, a small connection pool is often sufficient. The power lies in a single connection's ability to handle concurrent streams, not in having many idle connections waiting.

Quiz Questions 1/5

Why is using the default http.Client in Go often considered a performance trap for high-throughput microservices?

Quiz Questions 2/5

A Go microservice communicates with dozens of different external services. To optimize connection reuse, which http.Transport setting should be the primary focus?

By moving beyond the defaults, you can build robust, high-performance clients tailored to the specific demands of a microservices architecture.