Go HTTP Connection Reuse: What Changes in Go 1.2713 min read2,643 words

Go HTTP Connection Reuse: What Changes in Go 1.27

Go 1.27 improves HTTP/1 connection reuse by draining small unread response bodies. Learn why it matters, what changes, and how to write faster Go clients.

Go HTTP connection reuseGo 1.27 HTTP clientHTTP/1 keep-aliveGo response bodynet/http Transport
Go HTTP Connection Reuse: What Changes in Go 1.27

Go HTTP Connection Reuse: What Changes in Go 1.27

Why Go 1.27 Makes HTTP/1 Clients Faster

A Go function can make an HTTP request, read the status code, close the response body, and look totally fine. But if that code runs over HTTP/1 in a tight loop, it can end up opening a brand-new network connection for requests that "should" have reused the same one. The code is simple - the hidden cost is in what happens after Body.Close.

In a Go 1.26 benchmark that made 100 sequential HTTP/1 requests to index.golang.org, the run took about 710 ms on the Go team's test machine. The same benchmark on Go 1.27 landed around 150 ms. Server behavior, network conditions, and TLS settings all affect exact numbers, but the underlying reason is straightforward: with older behavior, an unread HTTP/1 response body can block connection reuse.

This article walks through why that happens, when it matters, and what changed in net/http.Transport in Go 1.27. It also covers the trade-offs in older Go versions, where "drain the body" workarounds can help - but can also burn time and bandwidth if you do it without limits.

Why a new HTTP connection costs time

An HTTP request isn't always just "send bytes, get bytes." If the client can't reuse an existing connection, it has to set up the path first. For HTTPS, that usually means creating a TCP connection and then negotiating TLS before the request can go out.

How many round trips that takes depends on what you're using and how it's configured. As a rough mental model:

  • A new TCP connection costs a network round trip.
  • A TLS 1.2 handshake typically adds a couple more round trips.

Then the client can send the HTTP request - after the setup work is done. That overhead happens before the server can start returning anything to your application.

On a low-latency network, you might not notice the cost for a single request. But across regions, latency adds up fast. If the server replies quickly with a small payload, the setup time can be a bigger fraction of total time than the "real" request work. Multiply that by every API call, and it becomes a pattern you don't want.

Reuse avoids repeated setup

Most HTTP clients will try to keep connections around so they can reuse them for later requests. People call this connection reuse or keep-alive. When it works:

  • The client can avoid creating a new TCP connection.
  • For HTTPS, it can avoid repeating the TLS handshake each time.

The details depend on the HTTP version:

  • HTTP/1.0 connections aren't persistent by default, though both sides often use Connection: keep-alive to request reuse.
  • HTTP/1.1 connections are persistent by default unless one side indicates it will close (for example, Connection: close).
  • HTTP/2 multiplexes multiple request/response streams on one connection. In HTTP/2, an unread response body on one stream doesn't block other streams the same way.

Reuse helps most for clients doing sequential requests to the same host. But "persistent" doesn't automatically mean "reusable." The client also has to know where one response ends and the next begins.

A simple benchmark can reveal the issue

Here's a function that only needs the status code:

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

}

The body is closed, so you'd expect the transport to be able to move on. The catch is that closing isn't always the same as consuming the whole response. In older Go versions, if the client didn't read the remainder of an HTTP/1 response, the transport may not reuse that connection for the next request.

That's why this can feel so "mysterious" in practice: the code looks careful, there's no obvious leak, and the wrongness doesn't show up as incorrect status codes. The performance hit is just repeated connection setup hidden behind an otherwise ordinary http.Get.

How an unread body blocks HTTP/1 reuse

With HTTP/1, responses are sent over a single byte stream, one after another. You get a status line, then headers, then the body. The "end" of the body is defined by HTTP framing - like Content-Length or chunked transfer encoding.

Go's net/http client reads enough of the stream to parse the status line and headers, then hands you a Response. At that point, the transport still has to treat the underlying connection carefully: until the body is consumed to its end, it can't safely treat the next bytes on the connection as the start of another response.

This isn't a preference - it's a framing problem. If the client stopped halfway through a response body and immediately tried to send another request on the same connection, the client wouldn't reliably know which incoming bytes belonged to which response.

Closing is not always consuming

Imagine a server returns a JSON body like:

{"success":true,"message":"update processed"}

Your code only needs the status code, so you don't read the body. You close it. On older Go versions, the transport may still treat that response as "unread" and refuse to reuse the connection, because the stream position is no longer at a known response boundary. The transport then discards the connection and has to establish a new one.

This can happen even when the remaining data is small. For example, encoding/json.Decoder can stop after decoding the JSON value. If the server sends a newline after the closing brace, that newline might never be read. The connection still isn't positioned at the end of the HTTP/1 response, even though it feels like it should be "one byte away."

The key point: HTTP/1 reuse depends on getting to the end of the current response before the connection can carry the next one.

Why HTTP/2 behaves differently

HTTP/2 doesn't use the same "one shared byte stream for everything" model. It multiplexes frames from multiple streams over one connection. If one response body is unread, it only occupies its own stream, so it doesn't automatically block reuse for other requests on the same connection.

That's not permission to ignore the body. You still need to close responses and release resources. It just means the specific HTTP/1 failure mode - unread data blocking the next response boundary on a shared stream - doesn't apply in the same way.

This matters when you're debugging. A workaround meant for HTTP/1 might add extra work for HTTP/2 without any benefit. The best move is to confirm which protocol is in play and where the time is really going.

The practical cost of failed reuse

If a single dropped connection doesn't matter, the story changes when you send lots of sequential requests to the same server - especially when the responses are small and the connection setup time is a big part of your latency budget.

The Go team's benchmark gives a sense of the scale: 100 sequential HTTP/1 requests took about 710 ms on Go 1.26 and about 150 ms on Go 1.27 in that test environment. That's close to a fivefold improvement, but you shouldn't treat it as a universal multiplier. Different hosts, networks, TLS versions, response sizes, and server behaviors all change the result.

What's consistent is the lesson about where the time is coming from. If your client keeps paying connection establishment costs, optimizing server-side processing might not be the first thing that helps. A behavior change in the transport can remove a lot more overhead than tweaking an application handler.

Common patterns that trigger it

This shows up most easily when the code doesn't actually need the response content:

  • A health check that only reads the status code.
  • A service that calls an API, but ignores the response message.
  • Code that reads one value from a response and then exits early.
  • A JSON decoder that stops at the end of a value while trailing whitespace/newlines remain.

None of these patterns are "wrong" by themselves. Reading and discarding a body you don't need is still work, and it can be wasteful if the response is large. The issue is that older HTTP/1 behavior could make the alternative - closing without reading - cause connection churn on the next request.

What manual draining fixes - and what it can cost

Before Go 1.27, a common workaround was to drain the remaining bytes into io.Discard before closing:

func drainAndCloseBody(body io.ReadCloser) {

io.Copy(io.Discard, body)

body.Close()

}

For a small response that arrives quickly, this can help the transport reach the response boundary and reuse the connection. But draining without limits is a bad habit: if the response is large, you may end up downloading a ton of data your application will never use, taking longer and consuming more bandwidth than opening a new connection would have.

A safer workaround caps what you read and often also caps how long you'll wait. But that adds complexity: you need to decide on byte/time limits, and you have to make sure the drain interacts correctly with closing and goroutine behavior. Even "just" making a helper is non-trivial when you want it to be correct and reusable across a codebase.

That's why sprinkling an unbounded io.Copy into request paths is rarely a complete production strategy.

What Go 1.27 changes and what to do

Starting in Go 1.27, net/http.Transport tries to drain an unread HTTP/1 response body asynchronously. The attempt is bounded: the transport will either spend up to 50 ms or read up to 256 KiB (whichever comes first).

If the drain completes within those limits, the connection can be reused. If it can't, the transport stops trying and continues without forcing your code to wait for an unlimited read.

Those limits are the point. The goal isn't to magically make every response reusable at any cost. It's to recover connections in the common "small and quick" cases, without turning your client into something that burns bandwidth or blocks on slow responses.

The transport can also skip draining when it won't help. For instance, if the response contains Connection: close, the server has already told you it won't keep the connection around, so draining solely to preserve reuse is just wasted work.

Keep application code straightforward

With Go 1.27, the recommended approach is still simple: close the response body when you're done.

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

}

You don't need to manually drain every response just to encourage HTTP/1 reuse. Let the transport do the bounded cleanup. Your application stays focused on the data it actually needs, and net/http handles the transport mechanics it already knows how to do well.

And yes, this doesn't remove the need to close bodies. You should still close responses even on error paths where you return a Response alongside an error. Go 1.27 just makes connection reuse more resilient when your code hasn't consumed the body fully.

What to do on older Go versions

If you're stuck on an older Go release and sequential HTTP/1 requests are slow, start by measuring. Don't add a generic "drain everything" helper everywhere and hope for the best.

A practical investigation looks like this:

  1. Confirm the protocol. HTTP/1 and HTTP/2 have different reuse behavior. If you can, use GODEBUG=http2client=0 to help isolate HTTP/1 behavior.
  2. Check whether requests are sequential and target the same host. Reuse only helps when a later request can share a connection that's still available.
  3. Inspect how response bodies are handled. Look for code that closes without reading, or decoders that stop early.
  4. Benchmark with realistic responses and latency. A local test with a tiny body might hide the costs that show up across regions or with TLS.
  5. If you add draining, bound it. Set a maximum byte count and consider the time cost. An unlimited drain can turn a performance optimization into a bandwidth/availability problem.
  6. Upgrade and retest where possible. The transport is part of the client implementation. In Go 1.27, the "right" behavior may already be built in.

For client and transport API details, see the net/http package documentation. The original explanation is also available in the Go blog post on HTTP connection reuse.

Avoid applying HTTP/1 workarounds to every protocol

Don't drain blindly everywhere. The goal is to prevent avoidable connection churn without doing more work than the request is worth.

For HTTP/2, consuming an unused body to help another request generally isn't necessary, because streams are multiplexed. For HTTP/1, reading the remainder can help reuse - but it can also cost time and bandwidth. Go 1.27 addresses the "common case" by doing bounded draining inside the transport.

Ultimately, the right behavior depends on protocol, body size, server behavior, and workload. In real codebases, the safest default is: keep application code simple, use the standard transport, and measure before adding protocol-specific machinery.

Conclusion

An HTTP/1 response body sits on the connection's shared byte stream. If the client closes before fully consuming the response, older Go transports may not reuse that connection for the next request. When this happens repeatedly, the cost of new TCP and TLS setup can dominate a workload that otherwise looks "lightweight."

Go 1.27 fixes the sharp edge by having net/http.Transport drain unread HTTP/1 bodies asynchronously, capped at 50 milliseconds or 256 KiB. For most applications, the guidance is simple: read what you need, close the body, and only add manual draining logic when measurement (and your Go version) point you there.

If you maintain a Go client, benchmark your real request pattern and confirm the protocol before changing code. Often, the simplest fix is letting the standard library handle a transport detail that it now deals with better.

Frequently Asked Questions

No. Closing releases the body, but on earlier Go versions it did not necessarily consume unread response bytes. Since HTTP/1 responses share a sequential byte stream, the connection might not be reusable until the current body reaches its end. Go 1.27 adds bounded asynchronous draining in the transport to improve this case.
No. Read the data your application needs and close the body when finished. Reading and discarding a large response can cost more than opening another connection. Go 1.27’s transport uses limits so that attempts to recover an HTTP/1 connection do not turn into unbounded downloads or waits.
The Go 1.27 behavior described here addresses unread HTTP/1 response bodies. HTTP/2 multiplexes separate streams over a connection, so an unread body on one stream does not block another request in the same way. You should still close response bodies to release resources.
Usually not for the purpose of encouraging HTTP/1 connection reuse. The standard transport now performs a bounded asynchronous drain when it can help. Keep closing response bodies, and retain custom handling only when your application has a specific requirement beyond connection reuse.

Related articles