Go 1.27's net/http.Transport drains unread HTTP/1 bodies in the background, capped at 50ms or 256KB. What changes once your minimum Go version moves to 1.27.
· 6 min read

Go 1.27 Drains Unread HTTP/1 Response Bodies So Connections Get Reused


Close an HTTP/1 response body without reading it and, on Go 1.26, the connection dies with it. Every request after that pays for a TCP handshake and a TLS handshake it shouldn’t have needed. Go 1.27 changes that: net/http.Transport now drains the leftover bytes in the background before deciding whether to throw the connection away.

The official Go blog post by Nicholas Husin and Damien Neil puts numbers on it. Calling a status-code helper 100 times against index.golang.org over HTTP/1 took about 710ms on Go 1.26 and about 150ms on Go 1.27. Identical code.

Here’s the 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
}

Nothing looks wrong. The body is closed, and there is no vet or lint warning. On Go 1.26 and earlier, though, leaving it unread prevents that HTTP/1 connection from being reused.

Why an unread body kills HTTP/1 connection reuse

HTTP/1.1 connections are persistent by default. Request/response pairs travel over the same TCP connection one after another, as a single stream of bytes. That sequencing is the whole problem.

When http.Get returns, net/http has read the status line and the headers. It has not read the body. The body gets consumed lazily as you read from resp.Body, so the transport’s read position on the socket is parked somewhere in the middle of response one. It can only find the start of response two once the current body has been fully consumed, and it only knows where that body ends from Content-Length or Transfer-Encoding.

Call Close() with bytes still pending and the transport picks between two bad options: read the rest, which costs time, or discard the connection. Through Go 1.26 it discarded. The next request then paid a TCP handshake (1 RTT), a TLS handshake (1–2 RTT depending on version), and only then got around to sending anything.

HTTP/2 is immune. It multiplexes independent streams of frames over one connection, so an unread body on stream 3 doesn’t block a request on stream 5. Which means most of the drain-the-body advice floating around the internet is pure overhead when someone copy-pastes it into an HTTP/2 client.

The near-miss case that catches everyone

You don’t have to ignore the body to hit this. You have to stop one byte early.

encoding/json.Decoder stops reading the moment it consumes the closing } of a value. If the server writes the JSON object followed by a newline, and plenty do, the decoder leaves that newline on the connection. One byte. Pre-1.27, one byte was enough to make the transport throw away a perfectly healthy connection.

func fetchUser(url string) (*User, error) {
	resp, err := http.Get(url)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var u User
	// Stops at the closing '}'. A trailing newline is left unread.
	if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
		return nil, err
	}
	return &u, nil
}

That is idiomatic Go. It’s in the tutorials. And it was quietly buying you handshakes you didn’t ask for.

Why the manual workaround was harder than it looked

The usual fix was to drain before closing:

io.Copy(io.Discard, resp.Body)
resp.Body.Close()

Fine for small bodies, harmful for large ones. If the server is streaming back a 500MB file you decided to abandon, pulling all of it down to save a connection is slower than just dialing again. So you cap the bytes:

io.CopyN(io.Discard, body, 256<<10)
body.Close()

Then you meet a server that trickles. Now you’re blocked for an unbounded stretch on a body you don’t want, so you cap the duration too, which means a goroutine and a select. And since nobody wants Close() to stall the caller for 50ms, the whole thing moves into the background to race your next request.

The blog post walks that progression and lands here:

func drainAndCloseBody(body io.ReadCloser) {
	drainDone := make(chan struct{}, 1)
	go func() {
		io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.
		drainDone <- struct{}{}
	}()
	go func() {
		select {
		case <-drainDone:
		case <-time.After(50 * time.Millisecond):
			// body.Close below interrupts the io.CopyN, so the
			// draining goroutine does not leak.
		}
		body.Close()
	}()
}

Two goroutines and a timer to close a response body. That’s the correct version, and almost nobody wrote it. Most codebases stopped at io.Copy and inherited the large-body problem, or skipped the whole thing and inherited the handshakes.

What Go 1.27 does instead

net/http.Transport now does the asynchronous drain itself for HTTP/1 responses: up to 50ms or 256KB, in the background. It also skips the work when draining buys nothing. If the response carried Connection: close, the connection is dead regardless, so there’s no point reading the remainder.

The Go team’s recommendation is to delete your drain helper and go back to the obvious 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 two limits are implementation details, not API guarantees. The blog says outright that the behavior may change. Don’t write logic that depends on exactly 50ms or exactly 256KB.

Verifying connection reuse in your own code

You don’t have to infer every connection decision from timings. net/http/httptrace gives you a GotConn hook whose Reused field reports whether that request received a previously used connection.

package main

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptrace"
)

func main() {
	trace := &httptrace.ClientTrace{
		GotConn: func(info httptrace.GotConnInfo) {
			fmt.Printf("reused=%v idle=%v remote=%s\n",
				info.Reused, info.WasIdle, info.Conn.RemoteAddr())
		},
		ConnectStart: func(network, addr string) {
			fmt.Printf("dialing %s %s\n", network, addr)
		},
		TLSHandshakeStart: func() {
			fmt.Println("tls handshake start")
		},
	}

	for i := 0; i < 5; i++ {
		req, err := http.NewRequestWithContext(
			httptrace.WithClientTrace(context.Background(), trace),
			http.MethodGet, "https://index.golang.org/index", nil)
		if err != nil {
			panic(err)
		}

		resp, err := http.DefaultClient.Do(req)
		if err != nil {
			panic(err)
		}
		// Deliberately not reading the body.
		resp.Body.Close()
	}
}

Run it with GODEBUG=http2client=0 to force HTTP/1 so the difference shows up. On Go 1.26, an unread body causes repeated dials. On Go 1.27, later requests can reuse a connection once its asynchronous drain has completed; an immediately following request can still race that drain and open another connection, so treat the trace as observed behavior rather than a guarantee that request two is reused.

Keep this trace around after you’ve confirmed the fix. It’s the fastest way to settle whether a client is pooling or dialing on every call, which is the same question that shows up when you share an http.Transport across clients or go fiddling with MaxIdleConnsPerHost.

Practical notes

You still have to close the body. The transport cannot start this drain until Close() is called; failing to close prevents timely reuse and can leave resources tied up.

Don’t add drain logic for HTTP/2 clients. It was never needed there, and as of 1.27 it isn’t needed for HTTP/1 either.

Abandoning a huge body is fine. The 256KB cap stops the transport from burning bandwidth on a response you walked away from; past that it drops the connection, which is the right call.

None of this is a timeout. Connection reuse and request deadlines are unrelated problems, and the default client still has no timeout.

Measure on your own traffic before you expect 5x. That figure came from 100 sequential requests to a real endpoint with zero body reads, which is the best case. How much you gain depends on how often your code leaves bodies unread and how expensive your handshakes are. The new benchmark loop makes that cheap to check.

If your minimum supported Go version is 1.27, a local drainAndCloseBody helper can usually go. Code that still runs on Go 1.26 or earlier does not get the new transport behavior, so remove compatibility logic only when the supported toolchain floor moves with it.