Contents
Go 1.27 changes what resp.Body.Close() does when the body was not read to the end on an HTTP/1 connection. Through Go 1.26 the Transport threw the connection away. In 1.27 it drains the rest of the body in the background and, if that finishes in time, returns the connection to the idle pool. The Go blog post describes the change and reports about 710 ms on Go 1.26 versus about 150 ms on Go 1.27 for 100 sequential calls against index.golang.org with GODEBUG=http2client=0. That is the Go team’s number from one machine, with no run count or variance given. I did not reproduce it against a remote host. What I did was measure where the drain stops working, because those limits decide how much of your own workaround code is now dead.
Why an unread body cost a connection
HTTP/1 responses on one connection are strictly sequential. The client cannot find the start of the next response until it has consumed the current body, using Content-Length or chunked framing to know where it ends. If the caller closes early, the Transport has two options: close the socket, or read and discard the remainder. Before 1.27 it chose the socket. The next request then pays a TCP handshake and, over TLS, a TLS handshake as well. HTTP/2 is unaffected because each response is its own stream.
Code that triggers this is common and does not look wrong: a health check that only looks at resp.StatusCode, a client that returns on a non-200 without reading the error body, or a decoder that stops before the end of the payload.
What 1.27 does, from the source
I read net/http/transport.go from the 1.27.2 tarball. Two constants set the limits:
const maxPostCloseReadBytes = 256 << 10
const maxPostCloseReadTime = 50 * time.Millisecond
In the read loop, a close before EOF (errClosedEarly) only attempts a drain when the connection is still alive and resp.ContentLength <= maxPostCloseReadBytes. Then maybeDrainBody starts a goroutine that runs io.CopyN(io.Discard, r, maxPostCloseReadBytes+1) and waits up to 50 ms for it. Only if that reaches EOF does the connection go back to the idle pool.
Two details matter in practice. The signal that lets the caller’s Close return (eofc) is sent before the drain starts, so Close does not block on it. And a chunked response reports ContentLength as -1, which passes the length gate, so for chunked bodies the 256 KB CopyN cap is the only size limit.
Measuring the edges
The harness is a loopback httptest server on HTTP/1 that sets Content-Length and returns a body of a chosen size. The client is a fresh http.Transport that does 100 sequential Get calls and calls resp.Body.Close() without reading. Server.Config.ConnState counts StateNew transitions, which is the number of connections the client opened. I ran it on Linux x86-64 with go1.27.2 and, for comparison, go1.24.7.
| Body size | go1.24.7 connections | go1.27.2 connections |
|---|---|---|
| 1 KiB | 100 | 2 |
| 100 KiB | 100 | 4 |
| 256 KiB | 100 | 2 to 3 |
| 257 KiB | not run | 100 |
| 1 MiB | 100 | 100 |
The cutoff is exactly where the source says. 262144 bytes drains and 263168 bytes does not. Wall time on loopback is a few milliseconds either way, so the connection count is the result worth trusting here, not the elapsed time. The handshake cost that makes this matter only shows up with real latency and TLS.
Where a sequential caller still dials
I added a response that sends one byte, flushes, waits, then sends the rest, so the unread tail is not already sitting in the client’s buffer. The 50 ms limit shows up as expected: with a 45 ms, 70 ms or 120 ms pause, all 100 requests opened a new connection. The part I did not expect is the shorter pauses.
With a 10 ms pause I got 64 to 69 connections per 100 requests across three runs, and with 30 ms I got 71 to 74. The drain was well inside the 50 ms budget, yet most requests still dialed.
The source explains it. Close returns before the drain finishes, and the connection joins the idle pool only after the drain reaches EOF. A caller that issues the next request immediately finds no idle connection, so it dials a second one. The first connection is returned to the pool afterwards and serves a later request. The effect is a small rotating set of connections instead of one reused connection, and it only appears when the unread remainder has not already arrived. For small bodies that the server wrote in one piece, the remainder is buffered and the drain completes before the next call, which is why the 1 KiB row sits at 2.
I have not verified why 10 ms reuses less often than 30 ms in my runs, and three runs per setting is not enough to call that difference real. The conclusion I would stand behind is narrower: a slow tail does not defeat the change, but it does not give you strict single-connection reuse either.
What I would change in existing code
The blog post’s recommendation is to close bodies normally and drop manual draining on 1.27, and the measurements support that for bodies under 256 KB that arrive promptly. I would keep or add explicit handling in three cases:
- Large responses you intend to abandon. Above 256 KiB the connection is discarded on 1.27 exactly as before, so
io.Copy(io.Discard, ...)is still wrong there for the reason the Go post gives: draining a large body can cost more than a new connection. Cancel the request context instead and accept the redial. - Slow servers. A tail that takes longer than 50 ms is not drained.
- Responses with
Connection: close. The Go post says draining is skipped for these because the server will close the socket anyway.
The Go post also states that this behavior might change in future versions, and the limits are constants rather than Transport fields, so there is nothing to tune. If you want a regression test for your own service, count connections on the server side as above. It is deterministic for the buffered case, which latency measurements are not.
Sources
- Better HTTP/1 connection reuse, Go blog, 14 Oct 2026. The 710 ms and 150 ms figures, the description of the behavior, and the
Connection: closeand future-change statements come from here. net/http/transport.gofrom the go1.27.2 source, for the constants, themaybeDrainBodylogic and the ordering ofeofcrelative to the drain.- The connection counts in the table and the slow-tail results come from my own loopback harness run on go1.27.2 and go1.24.7. The go1.24.7 slow-tail case was run once at 200 ms (100 connections) and is not tabulated.