On this page · 8 sections
  1. The mechanism in five steps
  2. Step one: what a new connection costs
  3. Step two: why HTTP/1 is different
  4. Step three: where the bug hides in ordinary code
  5. Step four: the manual workarounds
  6. Step five: what Go 1.27 changes
  7. Measuring the effect in your own services
  8. Sources

A function that only checks an HTTP status code can be several times slower than it needs to be, and nothing in the code looks wrong. The Go team's example makes 100 calls to index.golang.org with HTTP/1 and reports about 710 milliseconds on Go 1.26 and about 150 milliseconds on Go 1.27, on the author's machine. The difference comes from what happens to a connection when the caller never reads the response body. The Go Blog post on better HTTP/1 connection reuse explains the cause and the fix.

The mechanism in five steps #

Before looking at the code, it helps to see the sequence of events that decides whether a connection survives a request. The steps below follow the order in which they happen on the wire and inside net/http.

  1. The client opens a TCP connection, and for HTTPS it also runs a TLS handshake. Both cost round trips before any HTTP bytes move.
  2. The client sends a GET request, and the server writes back a status line, headers, and a body on the same byte stream.
  3. net/http.Get parses only the status line and headers, fills in a Response, and returns immediately. The body stays unread on the connection.
  4. The caller closes the body without reading it to the end. The transport sees unread bytes still sitting on the connection.
  5. Because HTTP/1 responses are sequential, the transport cannot reliably find the start of the next response. It abandons the connection, so the next request must pay the full setup cost again.

Step one: what a new connection costs #

Opening a connection is not free. The Go post breaks the setup into round trips. A TCP handshake takes one round trip (RTT), because the client can send its first TLS message alongside the final ACK. A TLS 1.2 handshake adds two more RTTs, and an HTTP request after that adds another. In cross-region traffic, where one-way latency can reach or exceed 50 milliseconds, the setup alone can dominate the total time. The author points out that even index.golang.org, which supports TLS 1.3 and therefore needs only one RTT for its TLS handshake, still shows a very noticeable difference.

This is why connection reuse matters. HTTP/1.0 uses an explicit Connection: keep-alive header, HTTP/1.1 keeps connections persistent by default, and HTTP/2 multiplexes many requests over one TCP connection. Reuse only works, though, when the client can finish one exchange cleanly and start the next one on the same socket.

Step two: why HTTP/1 is different #

In HTTP/1, requests and responses on one connection are sent one after another. If two GET requests share a connection, the replies arrive as a single stream of bytes. The reader must consume the first response body to its end, as marked by Content-Length or Transfer-Encoding, before it can locate the second response. An unread body therefore blocks everything behind it.

HTTP/2 avoids this particular trap. Its frames are multiplexed over one connection, so an unread body on one stream does not stop other requests from using that connection. This is why the problem is specific to HTTP/1 and why the post warns that some developers mistakenly drain bodies for HTTP/2 connections when doing so is unnecessary.

Note

Think of an HTTP/1 connection as a single-lane road with a toll booth at the end of each car's lane. The next car cannot enter until the previous car has fully passed through the booth. If a driver stops halfway to look at the scenery, the road is blocked and the next driver has to build a new road from scratch. An unread response body is that half-finished car.

Step three: where the bug hides in ordinary code #

The subtle part is that the code does not look broken. Here is the example from the post, which only needs a status code:

Go
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
}

Nothing here reads resp.Body, so through Go 1.26 every call can end with an abandoned connection. The post also shows that the bug is not limited to code that ignores bodies. An API might return a body such as a small JSON status message that the caller never uses. More subtly, encoding/json.Decoder stops as soon as it sees the closing brace of an object. If the server appends a newline, that single byte remains unread, and the connection cannot be reused until it is consumed. A healthy connection can therefore be discarded even though it was only one byte away from being reusable.

Step four: the manual workarounds #

Over the years, many Go developers wrote their own drain helpers. The usual pattern copies the rest of the body into io.Discard before calling Close. The post walks through the refinements that become necessary. Draining everything can be slower than opening a new connection when the body is large, so the drain must be capped by bytes. A server can also trickle a body slowly, so the drain must be capped by time as well. Finally, blocking for up to 50 milliseconds on every close is unacceptable, so the drain has to run in a background goroutine that races against the next request.

The author's conclusion is that this is "surprisingly tricky and cumbersome," and that nuances were lost as the practice spread. Teams copying helpers without understanding the limits could still get the wrong behavior, or spend effort on HTTP/2 where it was not needed.

Step five: what Go 1.27 changes #

Starting in Go 1.27, net/http.Transport performs this work itself. It asynchronously drains an unread HTTP/1 response body for up to 50 milliseconds or 256 KB, which matches the manual approach described above. It also adds an optimization: when a response carries Connection: close, draining would be wasted work, so the transport skips it. The post notes that this behavior may evolve in future versions as other enhancements become apparent.

The practical advice is simple. Close the body when you no longer need it, and let the transport decide whether draining is worth it. The post's recommended pattern is the same getStatus function shown above, with no custom drain helper.

Send request
→
Read status and headers
→
Close body early
→
Drain up to 50 ms or 256 KB
→
Reuse connection or dial new
Figure 1. The HTTP/1 path in Go 1.27, summarized from the Go Blog post's description of asynchronous draining.

Measuring the effect in your own services #

The benchmark in the post is deliberately small: a loop of 100 identical requests with a single function. Real services have more variables, including proxies, TLS versions, server keep-alive timeouts, and response sizes. The numbers should therefore be treated as the author's observation, not a guarantee. A reasonable way to check your own code is to run the same request pattern under Go 1.26 and Go 1.27 with HTTP/1 forced, using GODEBUG=http2client=0 as in the post, and compare the timings. If the difference is large, the bodies you are discarding are likely the cause.

The post also gives a clear boundary for when the old helpers still matter. A workaround that caps bytes and time is only needed for the older Go versions the post describes. Once your toolchain is 1.27 or later, adding custom draining code is more likely to add complexity than to improve reuse.

Key takeaways

An unread HTTP/1 response body can silently force a new TCP and TLS setup on every request, so close bodies you no longer need and let Go 1.27's transport drain small leftovers in the background. Avoid copying old drain helpers into new code.

Sources

  1. Better HTTP/1 connection reuse - The Go Programming Language go.dev · Oct 14, 2026

Also consulted

Next reading

Edited and approved by Jarosław Krzemiński. Spotted a mistake? Report a correction or read the corrections policy.