Software

Go 1.27 now drains HTTP bodies on Close: what we measured

By · Sat Oct 17 2026 · 9 min read · 0 views

View as a Web Story

Software#go#golang#go 1.27#net/http#http client

New TCP connections per 100 requests for unread HTTP response bodies on Go 1.26.9 and Go 1.27.2

Go 1.27 now drains HTTP bodies on Close: what we measured

What changed in Go 1.27 when you close a response body?

Closing an HTTP/1 response body now drains whatever is left, so the connection returns to the pool. The Go 1.27 release notes say the HTTP/1 Response.Body drains unread content on close. They add that setting Transport.DisableKeepAlives restores the old connection behavior.

The change was tracked as golang/go issue 77370, titled "net/http: drain response body after close." The issue states the limit: draining stops at 256 KiB or 50 milliseconds, whichever one is reached first. It was closed on August 20, 2026, after Go 1.27 shipped.

HTTP keep-alive is a feature that lets one TCP connection carry many requests in turn. A connection pool is the set of idle connections an HTTP client keeps for reuse. Before 1.27, the Go documentation said the transport might not reuse a keep-alive connection unless the body was read to the end and closed.

That old rule produced a well-known habit. Developers wrote io.Copy(io.Discard, resp.Body) before every Close. Go 1.27 makes that line optional for small bodies. It does not make it useless, as the measurements show.

How much does connection reuse improve?

It improves from 100 new connections per 100 requests to 2 for small unread bodies. We measured 100 sequential requests to a local server on Go 1.26.9 and Go 1.27.2, and counted new TCP connections. The client never read the body. It only called Close.

Unread body size New connections, Go 1.26.9 New connections, Go 1.27.2
1 KiB 100 2
16 KiB 100 2
64 KiB 100 4
128 KiB 100 10
256 KiB 100 16
257 KiB 100 100
512 KiB 100 100

Source: our test client and server, Go 1.26.9 and 1.27.2, October 16, 2026. Loopback network, one request at a time.

New TCP connections per 100 requests for unread response bodies of different sizes on Go 1.26.9 and Go 1.27.2

Three things stand out. First, Go 1.26.9 never reused a connection when the body went unread, at any size. Second, Go 1.27.2 reused almost every connection up to 64 KiB. Third, the benefit shrinks as the body grows, because the drain runs in the background and a new request can start before it ends.

The 256 KiB row opened 16 connections, not 2. That is still an 84% drop, but the drain is not instant. It is a race between your next request and the cleanup.

What happens at the 257 KiB cliff?

Connection reuse stops completely above 256 KiB. In our test, a 256 KiB body opened 16 connections per 100 requests, while a body one KiB larger opened 100. The limit is a byte count, so it behaves like a wall.

Advertisement

This matches the 256 KiB figure in the Go issue. When a body exceeds the limit, the transport gives up and closes the connection instead of draining it. The next request must dial again, with a new TCP handshake and, for HTTPS, a new TLS handshake.

On a loopback test, a dial costs microseconds. On a real network, a measurement study on TLS session resumption found that a TLS 1.2 full handshake takes one round trip for TCP plus two for the encrypted channel, and TLS 1.3 removes one of those. A service in Frankfurt calling an API in Virginia pays those round trips for every new connection. That cost is why this cliff matters.

Large bodies you do not need are common. Think of a 404 page, a proxy error or an unexpectedly large JSON list. If you ignore the body and the server sends 300 KiB, you lose the connection.

Do you still need io.Copy to io.Discard?

For example, yes for bodies that might exceed 256 KiB and for code that must run on older Go versions. For small error bodies, you can drop it on Go 1.27. The decision depends on size and on your support matrix.

We tested a short reply where the client read one small chunk and then closed. On Go 1.26.9 it opened 100 connections per 100 requests. On Go 1.27.2 it opened 1. That is the common case of a client that reads a little and stops.

An OneUptime engineering post from August 2026 reaches the same practical conclusion. It says explicit bounded draining remains useful for earlier Go releases. It also recommends closing before sleeping in a retry loop, and capping any manual drain at 64 KiB.

This helper keeps older behavior and bounds the work. It is safe on every Go version:

// drainAndClose reads at most 64 KiB so the connection can be reused,
// then closes the body. It never blocks on a huge or stalled response.
func drainAndClose(body io.ReadCloser) {
	io.Copy(io.Discard, io.LimitReader(body, 64<<10))
	body.Close()
}

Call it with defer drainAndClose(resp.Body). On Go 1.27 it costs almost nothing, because the body is usually already at EOF.

Why does a cancelled LLM stream still open a new connection?

Because the stream is still running, there is nothing to drain within 50 milliseconds. We built a server that sends one small event every 20 milliseconds for two seconds, like a chat completion. The client read three chunks and called Close.

Scenario Go 1.26.9 Go 1.27.2
Streaming reply, closed after 3 reads 20 new connections per 20 requests 20 per 20
Short reply, 1 small read, then Close 100 per 100 1 per 100

Source: our test client and server, October 16, 2026.

Connection reuse for three HTTP client patterns on Go 1.26.9 and Go 1.27.2: unread small body, partial read of a short reply, and a streaming reply closed early

The result is expected, since the drain gives up after 50 ms and the stream still has more than a second to run. The change helps short replies and does nothing for long streams. If your application streams model output and lets users press Stop, every Stop costs a new connection.

For these workloads, the fix is architectural. Keep a pool of connections to the model API and accept that cancelled streams close theirs. With HTTP/2, a cancel normally resets one stream and leaves the connection alone. This 1.27 change applies to HTTP/1 only, so check which protocol your client negotiates.

We did not test HTTP/2 in this run. Check your own client: count connections with httptrace.GotConnInfo, which reports whether each connection was reused.

How do you measure connection reuse in your own service?

Attach an httptrace hook and count Reused values. The hook runs for every request and tells you whether the transport reused a connection. This takes ten lines and works on any Go version.

var reused, fresh atomic.Int64

trace := &httptrace.ClientTrace{
	GotConn: func(i httptrace.GotConnInfo) {
		if i.Reused {
			reused.Add(1)
		} else {
			fresh.Add(1)
		}
	},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))

Log the ratio per minute. A healthy keep-alive client shows well over 90% reused. A ratio near zero means something is closing connections, often an unread body.

Why does this matter beyond latency? Also watch the counts of sockets in TIME_WAIT on the host. A service that opens a connection per request fills that table quickly under load. The symptom is cannot assign requested address errors, which usually appear only at peak traffic.

Consider a worker that calls a webhook endpoint 200 times a second and ignores the response body. Before 1.27, that is 200 dials a second. After the upgrade, the count can fall to nearly zero for small replies, with no code change.

How should a retry loop close failed responses?

Close each failed response before you sleep, and bound the drain. This is the pattern where the new behavior helps most: a loop that retries on 429 or 5xx and discards the body. Without a close, each failed attempt leaks a connection until the garbage collector runs.

We tested this helper against a local server that returned 503 twice and then 200:

func getWithRetry(c *http.Client, url string, tries int) (*http.Response, error) {
	var lastErr error
	for i := 0; i < tries; i++ {
		resp, err := c.Get(url)
		if err == nil && resp.StatusCode < 500 && resp.StatusCode != 429 {
			return resp, nil
		}
		if err == nil {
			lastErr = fmt.Errorf("status %d", resp.StatusCode)
			drainAndClose(resp.Body)
		} else {
			lastErr = err
		}
		time.Sleep(time.Duration(10<<i) * time.Millisecond)
	}
	return nil, lastErr
}

It returned status 200 after three server hits on both Go 1.26.9 and Go 1.27.2. The code is identical on both toolchains, which is the point: you do not need a version switch. On Go 1.27 the drain inside drainAndClose usually finds nothing left to read.

Two details make this safe. The sleep grows with each attempt, so a struggling server gets breathing room. The drain is capped at 64 KiB, so a misbehaving server cannot hold your worker. Add jitter to the sleep in production, so many clients do not retry in lockstep.

Finally, put a deadline on the whole call with context.WithTimeout. The 50-millisecond limit in Go 1.27 bounds only the drain on close, not a slow server. A request without a deadline can still hang.

What else does Go 1.27 change for HTTP?

Two nearby changes can affect you. The release notes say HTTP/2 now honors RFC 9218 priorities, and Server.DisableClientPriority opts out. They also say net.UnixConn reads return io.EOF unwrapped.

Neither is likely to break a typical service, although organizations running custom schedulers or hand-rolled socket wrappers should validate the interaction between these transport changes and their own instrumentation before promoting the toolchain. The first changes how a server schedules streams when a client sends priority hints. The second matters if you compare errors with == on Unix sockets.

Also note the go test change from the same release. The stdversion vet check now runs by default and flags standard library symbols newer than your go directive. Upgrading the toolchain without bumping the directive can produce new test failures.

Read Go 1.27 refuses to build with removed GODEBUG settings first. A removed setting stops the build. The JSON side of this release is in Does Go 1.27's json v2 break your LLM parsing? We tested.

What should you watch after the rollout?

Watch three numbers for a week: the reused-connection ratio, the count of sockets in TIME_WAIT, and p99 latency to your busiest upstream. A healthy rollout raises the first, lowers the second and trims the third. If none moves, your calls probably read their bodies already.

Also check dependencies. A client library that wraps net/http may drain on its own, which hides the gain. Compare the ratio on a service that calls the network directly with one that uses a wrapper before you declare the result, since the improvement attributable to the toolchain alone is easy to overstate when several layers of abstraction already perform their own cleanup.

Should you upgrade now or wait?

Upgrade now if you use HTTP/1 clients, because the change is free performance for small bodies. Keep your explicit drain in libraries that support older Go versions. Do not rely on the new behavior for large or streaming responses.

Here is our rollout order:

  1. Upgrade the toolchain and run the suite. Expect no failures from this change.
  2. Add the httptrace counter and record the reuse ratio before and after.
  3. Keep drainAndClose for bodies that could pass 256 KiB.
  4. Set Transport.DisableKeepAlives only if you must restore the old behavior, per the release notes.
  5. Review code that streams model output and decide how Stop should work.

The biggest gain goes to clients that call an API and ignore small error bodies. The smallest goes to streaming clients. Measure before you celebrate, and keep the before-and-after numbers in your change record so the next reviewer can see what the upgrade actually bought.

Advertisement

FAQ

Does Go 1.27 automatically drain the response body on Close?

Yes, for HTTP/1 responses. Closing an unread body drains the remaining content up to 256 KiB or 50 milliseconds, whichever comes first, so the connection can be reused. Bodies larger than the limit still close the connection.

Do I still need io.Copy(io.Discard, resp.Body) in Go 1.27?

Keep it for bodies that might exceed 256 KiB and for code that must run on older Go versions. For small bodies on Go 1.27 you can drop it. Cap any manual drain with `io.LimitReader`, for example at 64 KiB.

What is the Go 1.27 response body drain limit?

The limit is 256 KiB or 50 milliseconds, whichever one is reached first, according to golang/go issue 77370. In our test, a 256 KiB body reused connections, while a 257 KiB body opened a new connection for every request.

Does the Go 1.27 drain help streaming LLM responses?

Not for streams closed early. In our test, a stream cancelled after three reads opened a new connection on every request on both Go 1.26.9 and Go 1.27.2. The 50 ms limit expires long before a stream ends.

How can I restore the old connection behavior?

Set `Transport.DisableKeepAlives`, as the Go 1.27 release notes state. That opens a fresh connection for every request. In our test, it produced 200 connections for 200 requests on both toolchains, so use it only as a stopgap.

Comments

Loading…

Sign in to join the conversation.

Related posts