HTTP Transport and Connection Pool

Updated

September 8, 2026

HTTP Transport and Connection Pool

Overview

http.Client performance is mostly http.Transport: dialing, TLS, idle connection pools, HTTP/2 multiplexing. Using http.DefaultClient without timeouts is a top production mistake called out constantly in Go ops threads.

Diagram: Client stack

  Client.Do(req)
       │
       v
  Transport
       ├── Dial (+ TLS)
       ├── idle pool (per host)
       └── HTTP/1.1 or HTTP/2 streams
       │
       v
  resp.Body.Close() ──► return conn to pool

Client Stack

Client.Timeout (optional overall)
  -> Transport.RoundTrip
       DialContext
       TLSHandshake
       conn pool (idle conns per host)
       HTTP/1.1 or HTTP/2
tr := &http.Transport{
    Proxy:                 http.ProxyFromEnvironment,
    DialContext:           (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
    ForceAttemptHTTP2:     true,
    MaxIdleConns:          100,
    MaxIdleConnsPerHost:   10,
    MaxConnsPerHost:       0, // 0 = unlimited
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   5 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
    ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}

Pool Behaviors

Knob Effect
MaxIdleConns Global idle cache size
MaxIdleConnsPerHost Default is low (2) — often raise for chatty APIs
MaxConnsPerHost Hard cap; backpressure when hit
IdleConnTimeout Drop idle to free FDs
DisableKeepAlives Every request new dial — debug only

Failing to Body.Close() leaks connections out of the pool.

HTTP/2

When negotiated, multiple streams share one TCP connection. Benefits under many parallel requests to one host; watch head-of-line at other layers and flow control. Debug with GODEBUG=http2debug=1 carefully.

Server Side (Brief)

srv := &http.Server{
    ReadHeaderTimeout: 5 * time.Second,
    ReadTimeout:       15 * time.Second,
    WriteTimeout:      30 * time.Second,
    IdleTimeout:       60 * time.Second,
}

Timeouts are load shedding, not pedantry.

Experiment

go mod init example
go run .
package main

import (
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "time"
)

func main() {
    ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        _, _ = io.WriteString(w, "ok")
    }))
    defer ts.Close()

    tr := ts.Client().Transport.(*http.Transport).Clone()
    tr.MaxIdleConnsPerHost = 5
    client := &http.Client{Transport: tr, Timeout: 2 * time.Second}

    for i := 0; i < 3; i++ {
        resp, err := client.Get(ts.URL)
        if err != nil {
            panic(err)
        }
        _, _ = io.Copy(io.Discard, resp.Body)
        resp.Body.Close()
    }
    fmt.Println("requests ok", time.Now().Format(time.RFC3339))
}

What to notice: Closing bodies returns conns to the idle pool; omitting close eventually exhausts FDs under load.

Try next: Load test with MaxIdleConnsPerHost=2 vs 32 against a single upstream; compare dial rates in traces.