Executive Summary
I just read a post on the Go blog that digs into why HTTP/1 connection reuse in net/http can fail without it being visible on the surface, and how Go 1.27 finally patches this automatically. What surprised me: just because the response body wasn't read to completion, a benchmark of 100 requests to index.golang.org dropped from ~710ms on Go 1.26 to ~150ms on Go 1.27 — almost 5x faster, and that's just using the HTTP/1 path (GODEBUG=http2client=0).
Problem Being Solved
At its core, this is about the cost of creating a new connection. Every time a client opens a new connection to an HTTPS server, there's a TCP handshake (1 RTT), then a TLS handshake that costs 2 RTT with TLS 1.2 or can be 1 RTT with TLS 1.3 — so at minimum 3 RTT just to get ready before the first request is actually sent. For cross-region communication where RTT can be 100ms, that's already 300ms lost before the real request even runs.
The problem is that in HTTP/1 (both 1.0 and 1.1), responses are sent sequentially over the same single byte stream. net/http can only know where the next response starts once the current response body has been fully read up to the Content-Length or Transfer-Encoding limit. If our code only reads the status code and never touches resp.Body, up through Go 1.26 net/http would consider that connection unable to be reused, then discard the connection and create a new one on the next request — even though the server only sent a small body like {"success": true} that we ignored.
What's even more frustrating is that this can happen even when we "did" read the body. encoding/json.Decoder stops reading as soon as it finds the closing } of the JSON — if the server leaves a trailing newline after it, that single byte alone is enough to make the connection be considered non-reusable. HTTP/2 doesn't have this problem because it multiplexes multiple streams over one connection, so an unread body on one stream doesn't block other streams.
Key Features and Code Example
An example function that triggers this problem looks exactly like this:
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
}For a long time, many Go developers have written their own workaround for this, usually draining the body before closing it. The most complete version discussed in the article combines three things: limiting the number of bytes drained (max 256KB via io.CopyN), limiting the drain time (max 50ms via a goroutine + time.After), and running the drain asynchronously so it doesn't block Close():
func drainAndCloseBody(body io.ReadCloser) {
drainDone := make(chan struct{}, 1)
go func() {
io.CopyN(io.Discard, body, 256<<10) // Drain maksimal 256 KB
drainDone <- struct{}{}
}()
go func() {
select {
case <-drainDone:
case <-time.After(50 * time.Millisecond):
}
body.Close()
}()
}So here's the core of the news: starting with Go 1.27, net/http.Transport already does all of this automatically internally — asynchronously draining unread HTTP/1 bodies, with a 50ms or 256KB limit. There's an additional optimization too: if the response has a Connection: close header, the Transport immediately skips the drain process because the connection is going to be discarded anyway, so draining would just be wasted work.
Impact and Upgrade Notes
The new recommendation is simple: just defer resp.Body.Close() like the original code, no need to write a custom drainAndCloseBody anymore. This means the manual workarounds that have been circulating in many people's codebases (including the mistaken ones that also drain bodies on HTTP/2 connections, even though that's unnecessary) can now be simplified or removed because net/http has handled this internally since Go 1.27. The Go team also noted that this automatic draining behavior may still change in future Go versions as new optimizations are found.
---
First announced on the Go blog. Read the original source for full details.