Web Development · Go
Go 1.27 HTTP/1 Body Drain: Connection Reuse Fixed
Go 1.27 now drains unread HTTP/1 response bodies on Close, so connections get reused. What changes, what to delete, and how to test it in your client.
Abhishek Gupta
6 min read
Sponsored
Go 1.27 changes what resp.Body.Close() does on an HTTP/1 response. It now reads and discards any unread bytes before closing, so the connection can return to the idle pool instead of being torn down. If you have ever pasted io.Copy(io.Discard, resp.Body) above a defer resp.Body.Close() because a blog post told you to, most of that line is now the runtime’s job.
The wording in the Go 1.27 release notes is short. “HTTP/1 Response.Body now automatically drains any unread content upon being closed, up to a conservative limit, to allow better connection reuse.” The notes do not give the limit as a number, and we are not going to guess one.
What was wrong before
HTTP/1.1 keep-alive only works if the client has consumed the whole response. If a response body still has bytes on the wire when the client wants to reuse the socket, the next request’s bytes would land in the middle of the previous response. So Go’s transport did the safe thing: close the connection, open a new one next time.
That is a TCP handshake, usually a TLS handshake, and a cold congestion window, paid again for a response you had already decided to ignore. The common triggers were boring ones. A health check that only looks at the status code. A 404 branch that returns early. A client that reads the first 512 bytes of an error body for logging and stops.
Here is a program that shows it. It reads one byte of each response, closes, and counts how many connections the server sees across five sequential requests.
package main
import (
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
)
func main() {
var conns atomic.Int32
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, strings.Repeat("x", 4096))
}))
srv.Config.ConnState = func(_ net.Conn, s http.ConnState) {
if s == http.StateNew {
conns.Add(1)
}
}
srv.Start()
defer srv.Close()
client := &http.Client{Transport: &http.Transport{}}
for i := 0; i < 5; i++ {
resp, err := client.Get(srv.URL)
if err != nil {
panic(err)
}
buf := make([]byte, 1)
resp.Body.Read(buf) // read one byte, leave the rest
resp.Body.Close()
}
fmt.Printf("requests: 5, new connections: %d\n", conns.Load())
}
On Go 1.24.7 we got requests: 5, new connections: 5. Every request paid for a fresh socket. We could not run the same file on a 1.27 toolchain in the environment where we wrote this, so we are not going to print a “1.27 result” we did not see. Run it yourself after the upgrade. If the draining works as described for a 4 KB leftover, the count should drop to 1.
Should you delete your drain helper?
Maybe. Not everywhere, and not all at once.
A manual drain gives you something the new behavior does not: you choose the cap. A typical helper looks like this.
func drainAndClose(body io.ReadCloser) {
// Read at most 64 KB of leftovers; anything larger isn't worth a reused socket.
io.Copy(io.Discard, io.LimitReader(body, 64<<10))
body.Close()
}
If your clients talk to services that return small error bodies, the runtime’s drain covers you and the helper is dead weight. If they hit endpoints that can return megabytes (exports, log tails, proxied downloads), you want to know exactly how much you are willing to read before giving up, and the unnamed “conservative limit” does not tell you. Keep the helper there, and keep the cap explicit.
Our rule of thumb for a service we run: delete the helper in the code paths where the body is always tiny, keep it in the ones where it might not be, and leave a comment in both explaining which kind of path it is.
The case that can get slower
The release notes include one warning, and it is worth reading twice. Programs that “were improperly allowing an excessive amount of idle connections to linger” can see worse performance. The two patterns they name: setting Transport.MaxIdleConns to 0, and creating a different Client per request, which sidesteps the shared idle limit.
Why does that bite? Draining means connections now survive in situations where they used to die immediately. If your code accidentally depended on the old death to keep idle sockets down, they now pile up.
// Anti-pattern: a new Transport per call means every connection is its own pool.
func fetch(url string) (*http.Response, error) {
c := &http.Client{Transport: &http.Transport{}}
return c.Get(url)
}
The fix is not a flag. It is one shared Client for the process, with limits that you chose.
var httpClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
If you really do want no reuse (some short-lived CLI tools do), say so with DisableKeepAlives: true. The notes mention it as the way to turn reuse off. The same release notes also cover HTTP/2 priority signals and a new Server.MaxHeaderValueCount field, which sit outside this post.
How to roll it out
- Bump the toolchain in CI only. Leave the
goline ingo.modalone for a few days, the same order we suggested in our Go 1.27 upgrade guide. - Graph new outbound connections per second (or your proxy’s connection count) before and after. A drop is the expected outcome for clients that skip bodies. A rise means you have a client-per-request pattern to fix.
- Grep for
http.Client{andhttp.Transport{inside functions rather than at package level. Each is a suspect. - Check the usual pool-sizing math from our piece on database connection pooling, because the same reasoning (idle limits, timeouts, per-host caps) applies to HTTP clients.
One more thing about patch levels. Go 1.27.2 shipped on October 8 with 15 security fixes, several of them in net/http. If you are upgrading anyway, go straight to that version, and read what the 1.27.2 release fixes first.
The takeaway
Drain-on-close is a quiet default that removes a tax most Go services were paying without noticing. Run the five-request test before and after the bump, delete the drain helper only where bodies are always small, and treat any growth in idle connections as a sign that a client is being built in the wrong place.
Frequently asked questions
- Does Go 1.27 reuse HTTP/1 connections when I do not read the whole response body?
- Yes, within a limit. Closing the body now drains unread content automatically, up to a limit the release notes describe only as conservative. If the leftover body is larger than that limit, the connection is still closed instead of reused.
- Do I still need io.Copy(io.Discard, resp.Body) in Go 1.27?
- For small leftovers, no. For responses that can be large, an explicit drain with your own size cap still gives you control over how much you are willing to read. For most JSON APIs that you decode fully, you never needed it.
- Do I still need to call resp.Body.Close()?
- Yes. Drain-on-close is a behavior of Close. If you never call it, the connection and its goroutines stay tied up exactly as before.
- Which programs can get slower on Go 1.27?
- The release notes name programs that bypass the idle connection limit, either by setting Transport.MaxIdleConns to 0 or by using a different Client for each request. Draining can leave more connections lingering for them. Setting Transport.DisableKeepAlives to true turns off reuse.
Sources
Sponsored
More from this category
More from Web Development
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored