Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Published:

Tags:
  • go
  • net-http
  • http
  • connection-pooling
  • networking
  • performance

Go HTTP Response Bodies: Why Close Alone Loses Connections

This looks responsible:

resp, err := client.Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("unexpected status: %s", resp.Status)
}

The body is closed, so no file descriptor is leaked and no response object is left hanging around. Yet repeated calls can still create fresh TCP connections instead of reusing the transport's existing ones.

The missing operation is not another call to Close. It is reading the response body to EOF.

Close and EOF communicate different things

An HTTP/1.1 connection carries a sequence of responses as one byte stream. Before Go can send another request over that connection, it must know exactly where the current response ends. That boundary might be established by Content-Length, chunked transfer coding, or closure of the connection itself.

RFC 9112's persistence rules put it plainly: a client intending to reuse an HTTP/1.1 connection must read the entire response body. Otherwise, unread bytes from the first response would be sitting where the next response is expected. Guessing would be an entertaining way to invent a response-smuggling bug.

resp.Body.Close() means that the application has finished with the body. Reaching io.EOF tells the transport that the message boundary was actually consumed. Those are related events, but they are not equivalent.

The released Go 1.26 documentation consequently says that HTTP/1.x connections may not be reused unless the body is both read to completion and closed. In the transport, an internal body wrapper reports whether EOF was observed. Closing it early tells the connection-reading loop that reuse is unsafe, and the connection is discarded.

There is a version wrinkle worth knowing. Go's development documentation now describes a conservative, bounded attempt to drain a body asynchronously when it is closed. That improves the small-body case, but does not turn Close into a promise of reuse: a large, slow or faulty body can still exceed the drain policy. Code that deliberately wants reuse should continue to consume the body itself.

The common failure path is an error response

Successful responses are often decoded or copied completely. Error responses are where the shortcut appears:

if resp.StatusCode != http.StatusOK {
	resp.Body.Close()
	return fmt.Errorf("upstream returned %s", resp.Status)
}

A server may attach a JSON error document, an HTML proxy page or several megabytes of debugging output. Closing before reading it means an HTTP/1 transport cannot immediately return that connection to its idle pool.

The simplest repair is to discard the remainder before closing:

func drainAndClose(body io.ReadCloser) error {
	_, readErr := io.Copy(io.Discard, body)
	closeErr := body.Close()
	return errors.Join(readErr, closeErr)
}

Call this once processing is finished, including on status codes whose content is uninteresting. If io.Copy reaches EOF, the connection is a candidate for reuse. Reuse is still not guaranteed: the server may have sent Connection: close, a read may have failed, the request may have been cancelled, or the transport may have another reason to retire the connection.

Blind draining is not always sensible

An unlimited io.Copy hands the peer control over how much data and time the client spends trying to save one connection. A broken endpoint can stream forever. An attacker-controlled endpoint can do so deliberately. A client timeout or request context provides an outer bound, but reading gigabytes merely to preserve a socket remains a peculiar bargain.

A bounded drain makes that trade explicit:

func discardUpToAndClose(body io.ReadCloser, limit int64) (bool, error) {
	if limit < 0 {
		return false, errors.New("negative drain limit")
	}

	n, readErr := io.Copy(
		io.Discard,
		io.LimitReader(body, limit+1),
	)
	closeErr := body.Close()
	if readErr != nil || closeErr != nil {
		return false, errors.Join(readErr, closeErr)
	}

	return n <= limit, nil
}

The extra byte distinguishes a body that ended within the limit from one that merely filled it. If the function returns false, nil, it deliberately stopped early. On HTTP/1 the transport will normally abandon that connection, which is preferable to consuming an unbounded response.

A practical policy might drain up to 64 KiB from an unexpected response, while fully reading a successful response whose documented maximum is already enforced. The correct threshold depends on payload sizes, request rate and the cost of establishing a new connection, especially when DNS, TCP and TLS are all involved.

ContentLength can inform the decision, but it is not a complete answer. A value of -1 means the length is unknown, which is normal for chunked responses. Automatic decompression also changes what the caller sees: when net/http transparently decompresses a response, ContentLength is set to -1. A small compressed body can expand considerably, so the limit should apply to bytes actually read by the application.

Do not defer Close inside a long loop

Another innocent-looking pattern delays every close until the surrounding function returns:

for _, url := range urls {
	resp, err := client.Get(url)
	if err != nil {
		return err
	}
	defer resp.Body.Close()

	// Process one response.
}

With enough iterations, bodies and connections accumulate. Put one iteration in a helper function, or close explicitly before continuing:

func checkURL(ctx context.Context, client *http.Client, url string) error {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return err
	}

	resp, err := client.Do(req)
	if err != nil {
		return err
	}

	if resp.StatusCode != http.StatusNoContent {
		_, err := discardUpToAndClose(resp.Body, 64<<10)
		return err
	}
	return resp.Body.Close()
}

HTTP/2 changes the failure mode

This problem is chiefly about HTTP/1.x. HTTP/2 multiplexes separately framed streams over one connection. Closing a response body early can cancel that stream without making the underlying TCP connection unusable for every other stream.

That distinction does not make body management optional. Bodies must still be closed, reads can still block, and abandoning streams can waste server work or flow-control capacity. It simply means that a fresh TCP connection after every early close is not the expected HTTP/2 consequence.

Verify reuse rather than inferring it

The net/http/httptrace package can show whether a request received a reused connection:

trace := &httptrace.ClientTrace{
	GotConn: func(info httptrace.GotConnInfo) {
		log.Printf("reused=%t was_idle=%t", info.Reused, info.WasIdle)
	},
}

ctx := httptrace.WithClientTrace(req.Context(), trace)
req = req.WithContext(ctx)

Run several requests against the same HTTP/1.1 origin, first closing bodies immediately and then consuming them to EOF. The trace usually makes the difference obvious. Packet captures and server connection logs can confirm it, but httptrace is rather less theatrical.

The useful rule is compact: always close a response body, and read it to EOF when preserving the HTTP/1 connection is worth the work. If the peer controls an unbounded body, cap the drain and accept that sacrificing one connection may be the safer result.