Go 1.27 Finally Fixes HTTP/1 Connection Churn
Go's default Transport keeps just 2 idle connections per host. Here's the churn behind your p99 spikes — and the one-line fix.
Your Go service fires fifty concurrent calls at one internal API. Behind the scenes, net/http dials fifty TCP connections, negotiates fifty TLS handshakes, then closes forty-eight of them. The next burst starts over from zero. That behavior has been the default for over a decade, and the HTTP/1 connection reuse work in Go 1.27 is aimed straight at it.
You don't need to have read the release notes for this to affect you. It shows up as p99 latency and socket exhaustion, not as errors in your logs. Here are the mechanics, plus the one-line fix that works on every Go version you're running today.
Why this matters
Keep-alive exists so a connection survives one request and serves the next. Every fresh connection costs a TCP handshake plus at least one round trip of TLS, plus crypto work on both ends. Multiply that by thousands of requests per second and you get real CPU burn and real latency.
Worse, closed connections leave TIME_WAIT sockets on the client. Under sustained load, that's a classic path to ephemeral port exhaustion. And if the server caps concurrent connections, your client's dialing pattern can trip limits that look like random failures.
HTTP/2 is immune because it multiplexes everything over one connection. But plenty of traffic is still HTTP/1: internal services, legacy upstreams, hardware APIs, and anything behind a proxy that downgrades the protocol.
How it works
The http.Transport keeps a pool of idle connections. Two knobs govern it: MaxIdleConns, default 100 total, and MaxIdleConnsPerHost, default 2. Two. Per host.
So a burst of 50 concurrent requests opens 50 connections, finishes, and immediately closes 48 because the pool only holds 2. The next burst dials again. Connection churn in a perfect sawtooth.
Two aggravators: idle connections die after IdleConnTimeout (90 seconds by default), which races against servers that close idle keep-alives sooner — your request lands on a socket the server just killed. And if you don't close the response body, the connection never returns to the pool at all. No tuning survives that.
Go 1.27 makes the HTTP/1 transport hold onto and hand out idle connections more effectively, so bursts dial fewer new connections. Older versions need the manual fix below.
Where this helps
- API aggregators and BFFs fanning out to one upstream with high concurrency — the exact shape that triggers the default.
- HTTP/1-only endpoints: legacy services, device APIs, internal proxies where h2 never gets negotiated.
- Long-running batch jobs looping requests for hours, quietly accumulating
TIME_WAITuntil ports run out.
Watch out
Raising idle limits holds sockets open. If the server has strict connection caps or aggressive idle timeouts, a fat client pool just shifts the churn. Pair MaxIdleConnsPerHost with MaxConnsPerHost to cap total concurrency deliberately.
Align your IdleConnTimeout with the server's keep-alive timeout — a client holding connections longer than the server guarantees stale-socket errors. And none of this applies if ALPN upgrades you to HTTP/2; there, one connection carries everything.
Try it yourself
Run this against any local HTTP server and watch the dial counter across two bursts:
package main
import (
"context"
"fmt"
"net"
"net/http"
"sync"
"sync/atomic"
"time"
)
func main() {
var dials atomic.Int64
t := &http.Transport{
MaxIdleConnsPerHost: 100, // default is 2 — this is the whole fix
IdleConnTimeout: 90 * time.Second,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dials.Add(1)
return (&net.Dialer{Timeout: 5 * time.Second}).DialContext(ctx, network, addr)
},
}
client := &http.Client{Transport: t}
burst := func() {
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
res, err := client.Get("http://localhost:8080/")
if err == nil {
res.Body.Close() // no Close = no reuse, ever
}
}()
}
wg.Wait()
}
burst()
fmt.Println("dials after burst 1:", dials.Load())
burst()
fmt.Println("dials after burst 2:", dials.Load())
}Delete the MaxIdleConnsPerHost line and run it again. The jump in dials is the bug, live.
TL;DR
- What changed: Go 1.27 improves HTTP/1 idle-connection reuse; the old default kept just 2 idle connections per host.
- Why it matters: connection churn costs handshakes, CPU, and ephemeral ports — and it surfaces as p99 spikes, not errors.
- Try today: set
MaxIdleConnsPerHoston your hot-path transports, and close every response body.