Why drain an http.Response body with io.Copy(io.Discard, resp.Body) before closing it?
answer
- one connection, many responses, one byte stream
- where does the next response start?
- closed is not the same as finished
- io.Discard exists for its side effect
- read to EOF and closed, not merely closed
basics
~20 sGo's net/http Transport pools a keep-alive connection only after the response body is read to EOF and closed. Closing with unread bytes on the wire makes it drop the connection instead, so the next request re-dials.
solid answer
~40 sOn HTTP/1 the response body and the connection share one byte stream, so the Transport cannot find the start of the next response while unread payload is still in flight. If you `Close` a body you only partly read, the Transport's only safe move is to close the TCP connection instead of pooling it — and the next request pays a fresh DNS lookup, TCP handshake and TLS handshake. Reading to EOF first, typically `io.Copy(io.Discard, resp.Body)`, lets it be reused. Since Go 1.27 closing an HTTP/1 body auto-drains a bounded amount, so small leftovers no longer cost the connection, but a large remainder still does. In practice bound your own drain with `io.LimitReader` rather than swallowing an unbounded body just to save a handshake.
code
go · 13 linesresp, err := client.Do(req)
if err != nil {
return err
}
defer func() {
// read to EOF if it is cheap, so the connection can be pooled
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("push rejected: %s", resp.Status)
}go deeper
Know that reading the whole body before closing is the normal, intended shape, and that io.Copy(io.Discard, resp.Body) is the idiom for the paths where you did not read it - an early return on a bad status code, for example.
Explain the framing reason: one HTTP/1 connection carries responses back to back, so unread payload leaves the Transport unable to locate the next response and it must drop the connection. Be ready to say the condition is read to EOF and closed.
Show the production signature - no leak, just a fresh TCP and TLS handshake per request and steady socket churn against one host - and the judgment about bounding the drain rather than swallowing a huge body to save a handshake.
Own the policy: whether every outbound call goes through one wrapper that drains and closes correctly, how you keep that behaviour consistent across teams, and what latency signal would tell you a service has quietly stopped reusing connections.
### Framing is why it matters An HTTP/1.1 keep-alive connection carries one response after another over a single byte stream. The end of a response is defined either by its `Content-Length` or by the terminating zero-length chunk of a chunked body. Until those bytes have actually been consumed, the position of the *next* response on that connection is unknown. So when a caller closes a response body with payload still unread, Go's `net/http` Transport has exactly two choices: keep reading on your behalf, or throw the connection away. Historically it threw the connection away. That is the whole mechanism. The `http.Response` documentation states it plainly: the default Transport may not reuse HTTP/1.x keep-alive connections if the body is not read to completion and closed. "Closed" alone is not the condition; "read to completion **and** closed" is. ### What it looks like when you get it wrong The code compiles, the tests pass, and nothing is leaked — descriptors are released, because you *did* close. What you lose is reuse. Every request opens a new connection: * a DNS resolution (or at least a cache hit), * a TCP three-way handshake, * a TLS handshake, which for a fresh session means certificate verification and asymmetric crypto, * and, on the way out, a socket sitting in the kernel's post-close states for a while. On a client doing a handful of calls a minute this is invisible. On one doing hundreds a second against the same host it is a large, permanent latency floor and a steady churn of sockets and ephemeral ports — and it looks nothing like a bug, because the only symptom is "we are slower than we should be". ### The drain idiom ```go defer func() { _, _ = io.Copy(io.Discard, resp.Body) resp.Body.Close() }() ``` `io.Discard` is a writer that throws away everything written to it, so `io.Copy` here exists purely for its side effect of reading to EOF. Order matters: drain first, close second. In most code you never write this, because you already read the body to completion — `json.NewDecoder(resp.Body).Decode(&v)` normally stops at the end of the JSON value, `io.ReadAll` reads to EOF by definition. The idiom is for the paths where you *don't* consume the body: an early `return` on a bad status code, a decode that fails halfway, a caller that only wanted the headers. ### Bound the drain Draining is not free — you are transferring bytes you are going to throw away, over a network you are paying for. Against an untrusted or unbounded endpoint it is also a way to be made to read forever. The disciplined form caps it: ```go _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) resp.Body.Close() ``` If the remainder fits in the cap you reach EOF and keep the connection; if it does not, you gave up after a bounded amount and pay for a new connection — which is the right trade when the alternative is downloading megabytes to save one handshake. ### Go 1.27 made the common case automatic Recent Go closed most of this gap: on Go 1.27, closing an HTTP/1 response body drains a limited amount of the remaining data automatically so the connection can still be reused. That removes the penalty for the everyday case — a small error body you never read — without introducing an unbounded read. It does not remove the discipline: a body with more left than that bound still costs the connection, and code that must run on older toolchains still needs the explicit drain. Treat the automatic drain as a safety net, not as permission to stop thinking about it. ### HTTP/2 behaves differently On an HTTP/2 connection each request is its own stream multiplexed over one TCP connection, and framing is per-stream. Closing a body early cancels *that stream* (the client sends a stream reset) and the underlying connection carries on serving everything else. So the reuse penalty described here is an HTTP/1 phenomenon. Since Go's `http.DefaultTransport` negotiates HTTP/2 over TLS with servers that support it, the same client code can show the problem against one endpoint and not another — which is exactly the kind of thing that makes it hard to spot from the code alone. ### Where it sits relative to closing Closing and draining answer two different questions. Closing is about *resources*: fail to close and you hold a socket forever. Draining is about *reuse*: fail to drain and you release the socket but never get to use it again. A body you neither drained nor closed is both bugs at once; a body you closed without draining is only the second, which is why it survives code review so easily.
- The unread remainder is 200 MB of debug output you do not want. Do you still drain it?No. Draining exists to save a handshake, and 200 MB of transfer is far more expensive than a new connection. Cap the drain with `io.LimitReader` at something like tens of kilobytes, close, and accept the re-dial. The better fix is upstream: ask the endpoint for less, or use a request that does not produce that body.
- Does the same reuse penalty apply over HTTP/2?No. HTTP/2 multiplexes independent streams over one connection with per-stream framing, so closing a body early resets that stream and leaves the connection usable. The penalty is an HTTP/1 effect. Because Go's default Transport negotiates HTTP/2 over TLS when the server offers it, identical client code can show the problem against one endpoint and not another.
- If you already call io.ReadAll or json.NewDecoder on the body, do you still need the drain?`io.ReadAll` reaches EOF by definition, so no. A `json.Decoder` normally stops at the end of the JSON value; if the server appended trailing bytes, or the decode failed part way, the stream is not at EOF. That is why the drain belongs in the deferred cleanup, where it covers the failure paths as well as the happy one.
saying these in an interview costs you the question
- Says closing the body is enough for connection reuse
- Drains an unbounded body just to save a handshake
- Calls Close first and then tries to drain
- Thinks the Transport reads the rest in the background on every version
- Believes the penalty is a leak rather than a lost connection
- Assumes the behaviour is identical over HTTP/2