Backend 4 min read 830 words

Go 1.27 reuses HTTP/1 connections even if you don't read the response body

ES
Go 1.27 reuses HTTP/1 connections even if you don't read the response body

The Go blog has published Better HTTP/1 connection reuse, a short post explaining a Go 1.27 change that looks small but can make many programs noticeably faster without touching a line. It starts with this function:

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
}

It makes a GET request and returns the status code. That’s all. Called 100 times against index.golang.org over HTTP/1, it takes about 710 ms on Go 1.26 and about 150 ms on Go 1.27. Almost five times faster, with the same code.

What opening a connection costs

To understand the difference, it helps to remember what goes over the network for each new request:

StepRound trips (RTT)
TCP handshake1
TLS 1.2 handshake2
HTTP request1 or a little more

Across regions, a round trip can easily exceed 100 ms. So before the request is even sent, about 300 ms are gone just setting up the connection, when the response itself may cost little more than one round trip. That’s why HTTP clients try to reuse connections between requests: in HTTP/1.0 with Connection: keep-alive, in HTTP/1.1 by default, and in HTTP/2 by multiplexing several requests over the same connection.

Why reuse failed

In HTTP/1, responses arrive one after another over the same connection, as a single stream of bytes. http.Get reads the status line and headers, fills in the http.Response and returns. The body stays on the connection, unread, until someone reads it from resp.Body.

And here’s the problem: the client only knows where the next response starts once it has read the current one to the end. The function above never reads the body. Up to Go 1.26, when resp.Body.Close() was called, net/http saw there was unread data, gave the connection up as lost and opened a new one for the next request. Paying the full TCP and TLS cost every time.

It’s a silent failure, with no errors or warnings, and it shows up in code that looks perfectly correct:

  • You only care about the status code, but the API returns something like {"success": true, "message": "update processed"} that you never read.
  • You read the JSON, but not down to the last byte. json.Decoder stops reading as soon as it sees the closing }. If the server adds a trailing newline, that byte stays unread and the connection is thrown away, even though it was one byte away from being reusable.

HTTP/2 doesn’t have this problem, because each response travels on its own stream within the connection and an unread body doesn’t block the others.

The usual workaround

This problem has been around for years, and a workaround became popular in Go: drain the body before closing it.

func drainAndCloseBody(body io.ReadCloser) {
	io.Copy(io.Discard, body) // Drain the body before closing it.
	body.Close()
}

The interesting part of the post is that it shows doing this properly is harder than it looks:

  • If the body is large, downloading it just to throw it away can cost more than opening a new connection. You have to cap how much you read, say 256 KB with io.CopyN.
  • If the server sends the body slowly, you also have to cap the time, say to 50 ms.
  • And you don’t want Close() to block for those 50 ms, so you have to drain it in the background, with a couple of goroutines and a select.

The final version in the post has two goroutines, a channel and a timer for something that looked like one line. On top of that, as the workaround got copied from project to project, the nuances were lost: some people drain bodies on HTTP/2 connections too, where it isn’t needed at all.

What Go 1.27 does

Starting with Go 1.27, net/http.Transport does all of that on its own: it drains an unread HTTP/1 response body in the background, for up to 50 ms or 256 KB. And if the response carries Connection: close, it doesn’t even try, because the connection won’t be reused anyway.

The Go team’s recommendation is now simple: close the body when you no longer need it, as in the function at the start, and let net/http handle the rest. They do warn that the behaviour may be tuned in future versions.

My takeaway

I really like this kind of change: the standard library absorbs a problem everyone was solving by hand, sometimes badly, and the correct code also becomes the simplest. If you have a drainAndCloseBody or something similar in your projects, with Go 1.27 you can probably delete it. And if you never had one, your HTTP clients may get faster just by upgrading.

It fits with the idea behind go fix, which I wrote about a few weeks ago: Go tries to make idiomatic code also the most efficient, instead of making everyone learn the tricks.

Source: Better HTTP/1 connection reuse, Nicholas Husin and Damien Neil, The Go Blog.