HTTP Clients, Transport & Connection Pooling
Outbound HTTP in Go flows through http.Client, which delegates connection management to http.Transport.
Tuning timeouts and pool sizes prevents goroutine leaks, TLS stalls, and connection exhaustion under load.
Summary
http.Client is the high-level API for sending requests.
Its Transport field implements RoundTripper and owns TCP dial, TLS, HTTP/2 upgrade, and idle connection reuse.
Client.Timeout bounds the entire request including body read.
Finer control lives on Transport: DialContext, TLSHandshakeTimeout, ResponseHeaderTimeout, and idle connection limits.
Every response body must be closed so connections return to the pool.
Recipe
Quick-reference recipe card - copy-paste ready.
var apiClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 5 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := apiClient.Do(req)
if err != nil { return err }
defer resp.Body.Close()When to reach for this:
- Any service calling external HTTP APIs from handlers or workers.
- Replacing
http.Get/http.Postin production code paths. - Tuning keep-alive when QPS to one host is high.
Working Example
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"time"
)
type API struct {
base string
client *http.Client
}
func NewAPI(base string) *API {
return &API{
base: base,
client: &http.Client{
Timeout: 8 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 3 * time.Second,
ResponseHeaderTimeout: 4 * time.Second,
MaxIdleConns: 50,
MaxIdleConnsPerHost: 8,
IdleConnTimeout: 60 * time.Second,
},
},
}
}
func (a *API) Fetch(ctx context.Context, path string) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.base+path, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("status %d: %s", resp.StatusCode, string(b))
}
var out map[string]any
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
return out, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
api := NewAPI("https://httpbin.org")
data, err := api.Fetch(ctx, "/get")
if err != nil {
log.Fatal(err)
}
fmt.Println(data["url"])
}What this demonstrates:
- Dedicated
http.Clientwith explicitTransporttuning. NewRequestWithContextfor cancellation propagation.defer resp.Body.Close()on every success and error path.- Non-2xx handling with bounded error body reads.
Deep Dive
How It Works
Client.DocallsTransport.RoundTrip, which dials or reuses an idle connection.- Keep-alive returns TCP connections to the idle pool after
Body.Close(). MaxIdleConnsPerHostlimits reuse per upstream; excess connections close after the response.- HTTP/2 multiplexes streams on one TLS connection when ALPN negotiates h2.
Timeout Layers
| Layer | Field | Covers |
|---|---|---|
| Whole request | Client.Timeout | Dial through body read |
| TCP dial | Dialer.Timeout | Initial connection |
| TLS | TLSHandshakeTimeout | Handshake only |
| Server think time | ResponseHeaderTimeout | After request sent, before headers |
| Context | req.Context() | Caller cancellation |
Go Notes
// Inject tracing or auth without replacing Transport entirely:
type roundTripper struct { base http.RoundTripper }
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
req.Header.Set("Authorization", "Bearer "+token)
return rt.base.RoundTrip(req)
}
client.Transport = roundTripper{base: client.Transport}Parameters & Return Values
| Parameter | Type | Description |
|---|---|---|
Client.Timeout | time.Duration | Zero means no overall timeout |
MaxIdleConnsPerHost | int | Default 2 in older Go; raise for hot hosts |
IdleConnTimeout | time.Duration | Closes idle pooled connections |
DisableKeepAlives | bool | Forces new connection per request |
Gotchas
- Using
http.DefaultClientin servers - No timeout and shared transport across the process. Fix: Construct package-level or injected clients per dependency. - Not closing
resp.Body- Leaks connections until pool exhaustion. Fix:defer resp.Body.Close()immediately after successfulDo. - Discarding body on error status - Connection cannot reuse without draining. Fix:
io.Copy(io.Discard, resp.Body)before close on error paths. - Setting only
Client.Timeout- May hide need forResponseHeaderTimeouton slow servers. Fix: Layer transport timeouts for hung-header cases. - Sharing one Client with different timeout needs - All calls inherit the same limits. Fix: Separate clients per upstream SLA.
- Mutating
http.DefaultTransport- Affects entire process including libraries. Fix: Clone defaults into a newTransportstruct.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
resty / heimdall | Retry and backoff helpers wanted | You want zero dependencies |
| gRPC client | Strong contracts, streaming | Peer only speaks REST |
net/http + custom RoundTripper | Full control, stdlib only | You need a full HTTP client framework |
| Service mesh sidecar | mTLS and retries at data plane | Simple internal JSON calls suffice |
FAQs
What is the difference between Client.Timeout and context deadline?
Client.Timeout is fixed per client instance.
Context deadline varies per call and composes with parent cancellation.
Use both: client ceiling plus per-request context budget.
Why is MaxIdleConnsPerHost important?
Default idle limits per host are small.
High QPS to one API without raising it causes excessive TCP churn.
Should I reuse http.Client?
Yes.
Clients are safe for concurrent use once configured.
Create one per upstream dependency at process start.
How do I force HTTP/1.1?
Set TLSNextProto to an empty map on Transport for TLS connections, or configure ForceAttemptHTTP2 to false.
How do I add retries?
Implement retry logic in a custom RoundTripper or caller wrapper.
Respect Idempotency-Key and only retry safe methods or known idempotent paths.
Does Client follow redirects?
Yes, up to 10 redirects by default.
Set CheckRedirect on the client to customize or disable.
How do I send JSON POST bodies?
Use bytes.NewReader with NewRequestWithContext, set Content-Type, and pass the reader as the body.
What about connection limits under burst?
MaxConnsPerHost (Go 1.11+) caps concurrent connections per host.
Combine with client-side rate limiting for protection.
How do I use mutual TLS?
Set Transport.TLSClientConfig with loaded client cert and CA pool.
Rotate certs via custom GetClientCertificate callback.
Can I share Transport across clients?
Yes if timeout policies match.
Different Client.Timeout values can share one Transport.
How do I log outbound requests?
Wrap RoundTripper to log method, URL, status, and duration.
Avoid logging bodies with secrets.
What happens on context cancel during Do?
The in-flight request aborts and returns context.Canceled.
Still close any partial response body if Do returns a response.
Related
- net/http Basics - client GET example
- Middleware Chains & Request Context - propagate ctx outbound
- HTTP/2, Timeouts & ReverseProxy - HTTP/2 transport
- Cancellation Propagation in HTTP Handlers - ctx from handlers
- Production net/http Configuration Checklist - client checklist items
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).