Skip to content
gohacks.dev
Web

Transport Settings for a Production Go HTTP Client

Walk through every http.Transport field that matters in production, with the default value, what changing it does and measured effects of connection limits.

7 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

http.DefaultTransport is a reasonable general purpose configuration, and for a command line tool or a script it is the right one. A service that makes thousands of calls a minute to a handful of upstreams is not that situation. Its needs are specific: connection counts that match its concurrency, timeouts that match its dependencies, and protection against a slow or hostile server. All of that lives on the Transport, and it is worth knowing every field by name.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article goes through the ones that matter, shows how to start from the default so nothing is lost, and measures the effect of the two connection limits. It builds on client defaults and keep-alive, and belongs to the web cluster that ends in a production HTTP server. Everything ran on Go 1.25.6.

Start by cloning the default

Writing &http.Transport{...} from scratch has a trap. The default transport sets several things that are easy to lose: Proxy: ProxyFromEnvironment, a dialer with a 30 second timeout and keep-alive, ForceAttemptHTTP2: true, 100 maximum idle connections, a 90 second idle timeout, a 10 second TLS handshake timeout and a one second ExpectContinueTimeout. A literal that sets two fields and forgets the rest has no proxy support, no dial timeout (the dial can hang as long as the OS allows) and no HTTP/2. Clone the default instead:

Go
t := http.DefaultTransport.(*http.Transport).Clone()
t.MaxIdleConnsPerHost = 32
t.MaxConnsPerHost = 64
t.IdleConnTimeout = 30 * time.Second
t.ResponseHeaderTimeout = 5 * time.Second
client := &http.Client{Transport: t, Timeout: 10 * time.Second}

Cloning copies the current values, including the dialer, so you only write the lines that differ from the default, and a reader of your code sees exactly the policy you chose. The assertion on the type is safe unless something in the program replaced DefaultTransport with a different RoundTripper, as some instrumentation libraries do; in that case wrap the instrumentation around your cloned transport rather than the other way round.

The fields, in the order I would review them

MaxIdleConnsPerHost (default 2) is the single most valuable change. It caps how many idle connections are kept for each host. A service that makes ten concurrent calls to one API keeps two of those connections after the burst and closes the other eight, then dials eight again on the next burst. The measurement for it is in the reverse proxy article, where it was 82 connections against 10 for the same 100 requests. Set it to roughly the peak concurrency to that host.

MaxIdleConns (default 100) caps idle connections across all hosts. It only matters when you talk to many hosts, such as a crawler.

MaxConnsPerHost (default 0, unlimited) caps total connections to a host, including active ones. Excess requests wait for a connection to free up. This is your guard against a burst opening ten thousand connections to one upstream and being treated as an attack by it. It interacts with timeouts: a request waiting for a slot counts against Client.Timeout, so a saturated host shows up as timeouts, not as an error about connections.

IdleConnTimeout (default 90 seconds) drops idle connections. Keep it below the shortest idle timeout of the servers and load balancers in the path, as discussed in the keep-alive article.

ResponseHeaderTimeout (default 0, none) bounds the wait for response headers after the request is written. It is what turns an upstream that accepts connections and then goes silent into a quick error instead of a stuck goroutine. Combined with a dial timeout it covers everything before the body.

TLSHandshakeTimeout (default 10 seconds) and the dialer's Timeout (30 seconds) bound connection setup. For internal services, 2 to 5 seconds is plenty and surfaces a dead host faster.

ExpectContinueTimeout (default 1 second) matters for large uploads that set Expect: 100-continue: the client waits this long for the server's go-ahead before sending the body.

DisableCompression (default false) controls the automatic Accept-Encoding: gzip. When the transport adds the header itself, it also transparently decompresses the body and removes Content-Encoding. If you set Accept-Encoding yourself, the transport assumes you want the raw bytes and hands you the compressed body. People hit this when they copy headers from a browser capture into a request and then see binary garbage. The test below shows both.

Proxy (ProxyFromEnvironment by default) reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY. Set it to nil to bypass all proxies, which is sometimes what a test setup needs; see proxy support.

MaxResponseHeaderBytes (default 10 MB) limits the size of response headers. Lower it when calling services you do not trust.

Measuring what the limits do

Two experiments: how MaxConnsPerHost caps concurrency, and how automatic compression changes what the handler sees.

main.goGo
package main

import (
	"compress/gzip"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"sync"
	"sync/atomic"
	"time"
)

func main() {
	// 1. Six concurrent requests to a handler that takes 100 ms, with and without a connection cap.
	var inflight, peak atomic.Int64
	slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		n := inflight.Add(1)
		for {
			p := peak.Load()
			if n <= p || peak.CompareAndSwap(p, n) {
				break
			}
		}
		time.Sleep(100 * time.Millisecond)
		inflight.Add(-1)
	}))
	defer slow.Close()

	burst := func(maxConns int) (time.Duration, int64) {
		peak.Store(0)
		t := http.DefaultTransport.(*http.Transport).Clone()
		t.MaxConnsPerHost = maxConns
		c := &http.Client{Transport: t}
		defer t.CloseIdleConnections()
		start := time.Now()
		var wg sync.WaitGroup
		for i := 0; i < 6; i++ {
			wg.Add(1)
			go func() {
				defer wg.Done()
				res, err := c.Get(slow.URL)
				if err == nil {
					io.Copy(io.Discard, res.Body)
					res.Body.Close()
				}
			}()
		}
		wg.Wait()
		return time.Since(start).Round(100 * time.Millisecond), peak.Load()
	}
	d, p := burst(0)
	fmt.Printf("MaxConnsPerHost=0 (no cap): finished in %v, server saw %d at once\n", d, p)
	d, p = burst(2)
	fmt.Printf("MaxConnsPerHost=2:          finished in %v, server saw %d at once\n", d, p)

	// 2. Automatic gzip: the transport asks for it and undoes it; set the header yourself and it does not.
	gz := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Printf("  server received Accept-Encoding=%q\n", r.Header.Get("Accept-Encoding"))
		w.Header().Set("Content-Encoding", "gzip")
		zw := gzip.NewWriter(w)
		zw.Write([]byte("hello, compressed world"))
		zw.Close()
	}))
	defer gz.Close()

	fmt.Println("default transport:")
	res, _ := http.Get(gz.URL)
	body, _ := io.ReadAll(res.Body)
	res.Body.Close()
	fmt.Printf("  body=%q uncompressed=%v\n", body, res.Uncompressed)

	fmt.Println("Accept-Encoding set by hand:")
	req, _ := http.NewRequest("GET", gz.URL, nil)
	req.Header.Set("Accept-Encoding", "gzip")
	res, _ = http.DefaultClient.Do(req)
	raw, _ := io.ReadAll(res.Body)
	res.Body.Close()
	fmt.Printf("  got %d bytes, first bytes % x, uncompressed=%v\n", len(raw), raw[:2], res.Uncompressed)
}
Text
MaxConnsPerHost=0 (no cap): finished in 100ms, server saw 6 at once
MaxConnsPerHost=2:          finished in 300ms, server saw 2 at once
default transport:
  server received Accept-Encoding="gzip"
  body="hello, compressed world" uncompressed=true
Accept-Encoding set by hand:
  server received Accept-Encoding="gzip"
  got 47 bytes, first bytes 1f 8b, uncompressed=false

Without a cap, six requests ran side by side and the server saw six at once. With MaxConnsPerHost: 2, the server never saw more than two, and the burst took three waves of 100 ms instead of one. That is the trade you are making: the cap protects the upstream and your own file descriptors, and it adds queueing delay to your callers under load. Pick it deliberately, and watch for the queue showing up as latency.

The compression half shows the rule. With the default transport, the server saw Accept-Encoding: gzip, the transport decompressed, and Uncompressed was true. When the request set the header by hand, the body arrived compressed (the 1f 8b gzip magic bytes) and Uncompressed was false. Wrap the body in gzip.NewReader in that case, or remove the line and let the transport do it.

Per-host differences and where to put the code

Different upstreams deserve different transports. A vendor API with strict rate limits wants MaxConnsPerHost: 4; an internal service with a hundred replicas behind a load balancer wants a large idle pool; a flaky legacy system wants short timeouts and no keep-alive. Build each in one constructor, name it for the dependency, and inject the resulting client. Two lines in a shared NewHTTPClient(opts) that everybody calls are better than twenty copies of a literal with different mistakes.

The reference for every field, with its exact default, is the http.Transport documentation, and the Go team's net/http source is short enough to read when the documentation leaves a question open. Whenever you change these numbers, change them against a measurement: the connection count on the upstream's side, the p99 latency of the calls, and the number of timeouts. Without a graph, tuning is guessing, and it is the kind of change that makes a dashboard look better for a week and then fails during the first traffic spike.

Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.