Go 1.27 makes a very common HTTP/1 client pattern about 5x faster, and you don’t have to change any code to get it. In the Go team’s own benchmark, 100 sequential requests dropped from roughly 710 ms to roughly 150 ms.
func isHealthy(url string) (bool, error) {
resp, err := http.Get(url)
if err != nil {
return false, err
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusOK, nil
}
This code looks fine, and it is: it checks the status and closes the body. But before Go 1.27, whenever the response carried a body you hadn’t read, this function quietly threw away a TCP connection on every call. This post covers why that happened, what the Go team changed, and what it means for the code you already have.
This is my write-up of Better HTTP/1 connection reuse by Nicholas Husin and Damien Neil (Go blog, 14 October 2026). Read the original for the full story.
Why new connections are expensive
Every new HTTPS connection pays for handshakes before the first request can leave. Over TLS 1.2, that’s three round trips in total: one for TCP and two for TLS. At a 100 ms round trip, that’s 300 ms of setup for a request that might need just one more round trip to serve.
TLS 1.3 trims the TLS part to one round trip, so it hurts less. But even the Go team’s benchmark, which hits a TLS 1.3 endpoint, got almost 5x faster.
A new connection will do the handshakes first while a reused connection just sends the request.
Clients and servers reuse connections but the how depends on the HTTP version:
HTTP/1.0: opt-in, with aConnection: keep-aliveheader.HTTP/1.1: on by default, unless either side sendsConnection: close.HTTP/2: many requests and responses share one connection.
http.Get doesn’t download the whole response. It returns once the status line and headers are parsed, and resp.Body streams from the connection as you read it. That’s what ties reuse to how you handle the body.
Why an unread body breaks reuse in HTTP/1
HTTP/1 sends responses back to back on a single byte stream. http.Get returns as soon as it has parsed the status line and headers. The body? Still sitting on the connection. You pull it off as you read resp.Body.
Here’s the catch. The transport can only find where the next response starts once the current body has been read to the end (that’s defined by Content-Length or chunked framing). So if you close a body with bytes still unread, the connection’s in a state the transport can’t safely continue from. Through Go 1.26, its answer was simple: abandon that connection and dial a fresh one for the next request.
But HTTP/2 doesn’t have this problem. It multiplexes frames from separate streams over one connection, so an unread body on one stream doesn’t block the others.
Where it bites
It shows up in two common shapes:
- Status-only checks: Health checks and webhook calls often care only about the status code. But the server still sends a small JSON body you ignore.
- Decoding that stops one byte short:
json.Decoderstops reading at the closing}of an object. If the server ends its JSON with a newline, that newline stays unread. And the connection can’t be reused until someone consumes it.
var out Result
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return err
}
// The decoder stopped at the closing brace. A trailing "\n" may still be
// unread, which on Go 1.26 and earlier was enough to lose the connection.
A perfectly healthy connection gets abandoned while it’s one byte away from being reusable. It matters most for code that calls the same host over and over, like service-to-service calls or polling loops, because that’s where reuse pays off the most.
The old workaround - drain, then close
For a while, the community answer was to read the rest of the body before closing it:
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
That works for small responses but each fix uncovers another problem:
- Big bodies: Downloading and discarding a large remainder can take longer than opening a new connection. So you need a byte cap (
io.CopyN). - Slow bodies: A server that trickles data can hold you in the drain. So you need a time cap too.
- Blocking the caller: You don’t want
Closeto stall for up to the time cap. So the drain moves to a background goroutine, and now you’ve got to make sure that goroutine can’t leak.
The Go team’s post walks through exactly this progression and the habit spread to places it isn’t needed. Draining HTTP/2 bodies does nothing useful, but plenty of code does it anyway because the pattern got copied without its context.
What Go 1.27 does now
Starting in Go 1.27, http.Transport does that bounded background drain for you. Close an unread HTTP/1 response body, and the transport drains it asynchronously for up to 50 ms or 256 KB, whichever comes first.
- If the drain finishes before your next request goes out, the connection gets reused.
- If it doesn’t, the next request gets a new connection and the draining one can still land in the idle pool for a later request.
- If the response carried
Connection: close, the transport skips the drain entirely, since the connection’s going away regardless.
The tests in the commit that introduced this provide some specifics on the limits. A 200 KB unread body is expected to allow reuse, while a 500 KB one isn’t.
The Go team notes the behavior may evolve in later releases.
The official recommendation is now the simplest one, close the body when you’re done with it, and let net/http deal with the rest.
What to do in your own code
- Keep closing every body: This still applies on HTTP/2, where closing releases per-stream resources even though draining is pointless.
- Stop writing drain helpers for new code: On Go 1.27 and later the transport handles the HTTP/1 case, and the Go team recommends a plain
defer resp.Body.Close(). - Review the helpers you already have: They’re harmless on 1.27 but an unbounded
io.Copy(io.Discard, ...)can be slower than a new connection for big bodies, and any drain on an HTTP/2 path is wasted work. - Share one
http.Client: A new client or transport per request defeats connection pooling, no matter which Go version you run. - Measure instead of guessing:
httptracecan tell you directly whether each request reused a connection:
package main
import (
"fmt"
"net/http"
"net/http/httptrace"
"os"
"time"
)
func main() {
target := os.Args[1]
const requests = 100
reused := 0
start := time.Now()
for i := 0; i < requests; i++ {
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
if info.Reused {
reused++
}
},
}
req, err := http.NewRequest(http.MethodGet, target, nil)
if err != nil {
panic(err)
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
resp.Body.Close() // deliberately never read the body
}
fmt.Printf("%d requests in %v, %d on reused connections\n",
requests, time.Since(start).Round(time.Millisecond), reused)
}
Run it against an HTTPS endpoint that returns a body, forcing HTTP/1, once per toolchain:
GODEBUG=http2client=0 go run main.go https://example.com/
On Go 1.26 and earlier, you should see few or no reused connections. On Go 1.27, you should see many more but the drain races the next request, so don’t expect a perfect count and your timings depend on your network distance to the server.
Sources
- Better HTTP/1 connection reuse, Nicholas Husin and Damien Neil, The Go Blog, 14 October 2026
- net/http: try to drain response body upon closing for better connection re-use, mirror of the Go commit
- net/http: add clear documentation on when to drain & close a Response (#60240), Go issue tracker
- Drain and Close HTTP Responses Before Retrying, OneUptime, 15 August 2026