- Book: The Complete Guide to Go Programming
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
100 sequential status-only GETs to index.golang.org, forced onto HTTP/1 with GODEBUG=http2client=0, took about 710 ms on Go 1.26 and about 150 ms on Go 1.27 on the author's machine. That is the benchmark in the Go team's post Better HTTP/1 connection reuse, published 14 October 2026 by Nicholas Husin and Damien Neil. The program itself didn't change. The standard library started doing something Go developers had been told to do by hand for years.
You know the habit. Every code review that touches an HTTP client eventually gets a comment asking for io.Copy(io.Discard, resp.Body) before resp.Body.Close(), so the connection goes back to the pool. Go 1.27 moved that into net/http.Transport. It also put limits on it, and the limits are where your connections still die.
What Go 1.26 did with a body you didn't finish
HTTP/1 sends responses back to back on one TCP connection. The client can't send the next request on that connection until it has consumed the current body to its end, as the Content-Length or Transfer-Encoding header defines it. If you closed the body early, Go 1.26 and earlier gave up on the connection and dialed a new one, with a fresh TCP handshake and, for HTTPS, a fresh TLS handshake.
The blog post points out how easy this is to trigger by accident. A json.Decoder can stop reading right before the trailing newline of a JSON body. One unread byte, and the connection is gone.
Go 1.27 changes Close. The release notes say HTTP/1 Response.Body "now automatically drains any unread content upon being closed, up to a conservative limit." The work was tracked in golang/go#77370, which spells the limit out: "up to a limit of 256k bytes, or 50 milliseconds, whichever one is reached first." HTTP/2 is not affected, because its streams are multiplexed and an unread body on one stream doesn't block the others.

Small, fast leftovers go down the drain. Anything bigger or slower takes the connection with it.
The exact rule, from transport.go
"256 KB" sounds like "the first 256 KB of whatever is left." It isn't. In transport.go on the 1.27 release branch, the decision to try a drain is one line:
tryDrain := alive && resp.ContentLength <= maxPostCloseReadBytes maxPostCloseReadBytes is 256 << 10 and maxPostCloseReadTime is 50 * time.Millisecond. Four things follow from that code:
- The check uses the declared
Content-Lengthof the whole response, not the bytes you left unread. A 300 KB body is never drained, even if you read all but the last 100 bytes. - A response with no length (chunked) has
ContentLength-1, so it passes the check. The drain then copies at most 256 KB + 1 byte in a goroutine and only keeps the connection if it hits EOF. - The drain gives up after 50 ms. A server that pauses mid-body costs you the connection.
- If the request or the response says
Connection: close,aliveis already false and no drain runs.
One more detail matters for measuring this. The read loop lets Close return before the drain starts. The related fix is tracked as CL 741222, "prevent blocking when draining response body after it has been closed," linked from the same issue. Your Close call doesn't wait. The drain happens behind it.
A demo that counts connections from the server's side
The cleanest way to see reuse is to count new connections where they arrive. http.Server.ConnState is called with StateNew once per accepted connection, and httptest keeps your hook when it wraps the server. The program below runs 100 GETs against a local server, reads -read bytes of each body, closes it, and prints how many connections the server accepted.
package main import ( "flag" "fmt" "io" "net" "net/http" "net/http/httptest" "strconv" "strings" "sync/atomic" "time" ) func main() { size := flag.Int("size", 4<<10, "body size in bytes") read := flag.Int64("read", 0, "bytes the client reads") chunked := flag.Bool("chunked", false, "no Content-Length") stall := flag.Duration("stall", 0, "pause mid-body") flag.Parse() body := strings.Repeat("x", *size) half := *size / 2 h := func(w http.ResponseWriter, r *http.Request) { if !*chunked { w.Header().Set("Content-Length", strconv.Itoa(*size)) } if *chunked || *stall > 0 { w.(http.Flusher).Flush() } io.WriteString(w, body[:half]) if *stall > 0 { w.(http.Flusher).Flush() time.Sleep(*stall) } io.WriteString(w, body[half:]) } var newConns atomic.Int64 srv := httptest.NewUnstartedServer(http.HandlerFunc(h)) srv.Config.ConnState = func(c net.Conn, s http.ConnState) { if s == http.StateNew { newConns.Add(1) } } srv.Start() defer srv.Close() client := srv.Client() for i := 0; i < 100; i++ { resp, err := client.Get(srv.URL) if err != nil { panic(err) } io.CopyN(io.Discard, resp.Body, *read) resp.Body.Close() } fmt.Println("new connections:", newConns.Load()) } The Flush before the first write in chunked mode matters. The ResponseWriter.Write docs say the server adds Content-Length automatically when the output is under a few KB "and there are no Flush calls." Flushing first sends the headers without a length, so the body goes out chunked.
Save it as main.go, then run it under both toolchains. If you have 1.27 installed, GOTOOLCHAIN=go1.26.0 go run main.go fetches and uses 1.26 for the comparison run. I haven't run these myself; the expectations below come straight from the code quoted above.
go run main.go # 4 KB, read nothing go run main.go -size 300000 -read 299900 go run main.go -chunked -size 100000 go run main.go -chunked -size 400000 go run main.go -stall 100ms 
Count connections where they're accepted, not where they're requested.
What each run should show
Run one, 4 KB with nothing read: on 1.26 every Close is early, so expect 100 new connections. On 1.27 the drain picks up the 4 KB and the connection goes back to the pool, so the count should drop to a small number. It may not be exactly 1. Because Close returns before the drain finishes, the next Get can find no idle connection and start a dial, and on loopback a dial is fast enough to win that race sometimes.
Run two, 300,000 bytes with all but 100 read: declared length is over 262,144, so 1.27 never tries. Expect about 100 on both versions. This is the case where "it drains for you" is most misleading.
Run three, chunked 100,000 bytes: ContentLength is -1, the drain reads the rest within 256 KB + 1 and hits EOF, so 1.27 should reuse.
Run four, chunked 400,000 bytes: the drain copies 256 KB + 1 without reaching EOF and drops the connection. Back to about 100.
Run five, a 100 ms pause mid-body: the 50 ms timer fires before the second half arrives. About 100 on both, and each server handler is still sleeping when the client hangs up.
What to do with the drains already in your code
If your module only builds with Go 1.27 or later, delete the io.Copy(io.Discard, resp.Body) lines and keep defer resp.Body.Close(). That follows the blog's advice to just close the body. An unbounded io.Copy was always a liability anyway: point it at a multi-gigabyte download you meant to abandon and it reads the whole thing on your goroutine. The release notes list no GODEBUG setting for this change, so the behavior comes with the 1.27 standard library.
If you still support 1.26 or earlier, keep a drain but bound it. The blog's version caps bytes at 256 KB. The standard library copies 256<<10 + 1 bytes instead. If exactly 256 KB is left, a copy capped at 256 KB stops without reading EOF, and the transport only reuses a connection whose body reached EOF. The extra byte tells "exactly 256 KB" apart from "more".
func drainAndClose(body io.ReadCloser) { io.CopyN(io.Discard, body, 256<<10+1) body.Close() } Run it as go drainAndClose(resp.Body) if you don't want the caller to wait on a slow peer. The blog also describes adding a timer that calls body.Close after 50 ms to interrupt the copy.
For responses you know you'll abandon and that are large, a drain won't help on any version. Avoid fetching what you don't need: a HEAD request, a Range header, or a paginated endpoint. When you will read a status line and walk away from a big body, set req.Close = true. The transport sends Connection: close, skips the drain, and the server knows not to keep the connection open for you. The blog notes that for large bodies a new connection can be cheaper than draining, which is why the limits exist.
For slow peers, the 50 ms drain timer protects connection reuse, not your own reads. Keep a Client.Timeout or a context deadline on the request.
The one setup this can make slower
The release notes call out a case that gets worse. Programs that were "improperly allowing an excessive amount of idle connections to linger," usually by setting Transport.MaxIdleConns to 0 or by using different Clients for different requests, may now see degradation, because connections that used to die early now survive into the idle pool. The fix they give is Transport.DisableKeepAlives = true if you truly don't want reuse. They add that this usually points to improper configuration or usage of Transport or Client in the first place, and that a deeper look would likely be beneficial. In practice, the common repair is sharing one Client instead of building a new one per request.
If this was useful
This change is a good example of why it pays to know what net/http does underneath Close: the rule lives in one line of transport.go, and it decides whether your client handshakes once or a hundred times. The Complete Guide to Go Programming is where I cover the standard library at that level, along with the runtime and the concurrency model that goroutine-based drains like this one sit on.


