Source: Better HTTP/1 connection reuse, by Nicholas Husin and Damien Neil
A Go function that checks a status code and closes the response without reading the body loses its HTTP/1 connection on Go 1.26. Every next call pays a new TCP and TLS handshake. Go 1.27 drains the body inside the transport, so the same code reuses the connection.
The blog opens with this function. It sends a GET request and returns the status code.
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
Using HTTP/1 (GODEBUG=http2client=0), calling it 100 times against index.golang.org takes about 710 milliseconds on Go 1.26 "on my machine", in the authors' words. On Go 1.27 the same program takes about 150 milliseconds. The code did not change.
On HTTP/1, responses on one connection arrive back to back as a single stream of bytes. http.Get parses the status line and headers, then returns. The body stays unread on the connection. Go can find the start of the next response only after the current body has been read to the very end, as set by Content-Length or Transfer-Encoding.
Close with an unread body on Go 1.26: the next request pays the setup again.
Through Go 1.26, resp.Body.Close sees the unread body, abandons the connection and opens new ones for the next requests. A new connection costs a TCP handshake (1 RTT) and a TLS handshake (2 RTT with TLS 1.2; 1 RTT with TLS 1.3, which index.golang.org supports). In cross-region traffic, one-way latencies often reach 50 ms (100 ms round trip), so with TLS 1.2 setup takes about 300 ms before the request itself.
Reading part of the body is not always enough. encoding/json.Decoder stops at the closing }. If the server ends the JSON with a newline, that byte stays unread and, per the blog, the connection can be abandoned one byte short of reuse.
The common workaround is to drain the body before closing it.
func drainAndCloseBody(body io.ReadCloser) {
io.Copy(io.Discard, body) // Drain the response body before closing it.
body.Close()
}
A large body can take longer to discard than a new connection takes to open, so the blog caps the bytes:
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
A slow server can trickle the body in, so it also caps the time at 50 ms. And to avoid blocking Close for up to 50 ms, the final version drains in the background and races the next request:
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
drainDone <- struct{}{}
}()
go func() {
// Only spend at most 50 ms trying to drain the response body.
select {
case <-drainDone:
// If the drain can finish before a new request is sent, the
// connection will be reused; otherwise, a new connection is
// created, and the current connection will be put in a pool of
// idle connections, potentially being reused for another future
// request.
case <-time.After(50 * time.Millisecond):
// If we reach the 50 ms timeout, body.Close will run and interrupt
// the io.CopyN, preventing goroutine leaks.
}
body.Close()
}()
}
net/http.Transport drains an unread HTTP/1 response body asynchronously, for up to 50 ms or 256 KB.Connection: close, since draining is then wasted work.getStatus.HTTP/2 was not affected. It multiplexes frames from many streams over one connection, so an unread body on one stream does not block other requests. The blog adds that draining HTTP/2 bodies was never necessary.
Count new connections with net/http/httptrace: a GotConn hook receives GotConnInfo, and Reused is false for every new connection. A small local repro on go1.26.3 against an httptest server that returns {"success": true, "message": "update processed"} plus a newline, 100 requests per mode:
| Mode | New connections | Reading |
|---|---|---|
| Close only | 100 of 100 | Every request opened a new connection. |
| JSON decode, then Close | 1 of 100 | The blog's trailing-newline loss did not reproduce with this small local body; only the first connection was new. |
| Drain, then Close | 0 of 100 | Every request reused a connection (the first one was opened by the earlier mode). |
This is one local run with a tiny body, not a benchmark; it shows the close-only pattern, not timings.
Better HTTP/1 connection reuse on the Go blog, and the Hacker News discussion.
Sources: 710 ms, 150 ms, 256 KB, 50 ms and RTT figures from the Go blog post; connection counts from a local repro on go1.26.3 (darwin/arm64).