An HTTP request can appear to work while quietly making later requests more expensive. A common cause in Go clients is mishandling http.Response.Body: forgetting to close it, returning before cleanup is arranged, or assuming that an HTTP error status is returned as a Go error.

The important mental model is that a successful Client.Do call gives your code ownership of a stream, not a byte slice. The response headers have arrived, but the body is consumed as you read it. Your code must decide how much of that stream it needs and must close it when finished.

That ownership rule also affects persistent connections. With the standard http.Transport, an HTTP/1.x connection may not be reusable unless the response body reaches EOF and is closed. Correct body handling is therefore part of both resource cleanup and connection-pool behavior.

Start with the ownership rule

For a response returned without an error, arrange cleanup immediately:

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

resp.Body is non-nil when Do returns a nil error. Closing it is the caller’s responsibility.

The defer belongs immediately after the error check. That makes later returns in the function safe: status validation, decoding errors, and application-level checks cannot accidentally skip the close.

Do not write this:

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

If Do fails, resp is not a response your code can safely dereference. Check the error first.

HTTP failure and transport failure are different

A non-2xx HTTP status does not make Client.Do return an error. A 404 Not Found or 503 Service Unavailable is still a successfully received HTTP response.

That means client code normally needs two error paths:

resp, err := client.Do(req)
if err != nil {
    return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}

The first path covers failures such as inability to complete the HTTP exchange or cancellation. The second path applies application policy to a response that actually arrived.

Keep that distinction because callers often react differently. Retrying a temporary network failure may be reasonable, while retrying a 400 Bad Request usually is not unless the request itself changes.

Read the body when the result depends on it

For small responses, reading the complete body is straightforward:

body, err := io.ReadAll(resp.Body)
if err != nil {
    return fmt.Errorf("read response body: %w", err)
}

A successful io.ReadAll consumes through EOF. Combined with the deferred Close, this gives the default transport the conditions it needs to reuse an eligible persistent connection.

But io.ReadAll has no application-level size limit. If the peer can send an unexpectedly large response, the program may allocate far more memory than intended. Use a limit when the protocol or application has a known maximum.

Put a hard boundary on small responses

Suppose an endpoint should never return more than 1 MiB. Read at most one byte beyond that limit so the program can distinguish an exactly-full response from an oversized one:

const maxBody = 1 << 20 // 1 MiB

limited := io.LimitReader(resp.Body, maxBody+1)
body, err := io.ReadAll(limited)
if err != nil {
    return fmt.Errorf("read response body: %w", err)
}
if len(body) > maxBody {
    return fmt.Errorf("response body exceeds %d bytes", maxBody)
}

io.LimitReader returns EOF after the configured number of bytes even if the underlying response has more data. The extra byte is therefore intentional: if the result is larger than maxBody, the application knows the response exceeded its contract.

There is an important consequence. On the oversized path, the underlying HTTP body has not necessarily reached its real EOF. Closing it is still required, but that particular connection may not be reusable. This is a sensible trade-off when the alternative is reading an untrusted or unexpectedly huge response merely to preserve a connection.

Do not turn connection reuse into a reason to consume unlimited data.

Decode streaming formats without buffering everything

When the response is naturally streamable, decode directly from the body:

type User struct {
    ID   int64  `json:"id"`
    Name string `json:"name"`
}

var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
    return fmt.Errorf("decode response: %w", err)
}

This avoids first materializing the complete response as a separate []byte. It does not automatically impose a size limit, and a single JSON value can still contain very large strings, arrays, or objects. Streaming and bounding are separate concerns.

If a hard total-body limit is part of the contract, place a limited reader in front of the decoder and design the limit check carefully. A decoder may finish one JSON value before consuming every remaining byte, so merely wrapping it in io.LimitReader does not prove that the entire response was within the limit or contained no trailing data.

Treat response bodies as function-scoped resources

A helper that returns decoded data is often easier to use safely than one that exposes *http.Response unnecessarily:

func fetchUser(ctx context.Context, client *http.Client, url string) (User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return User{}, fmt.Errorf("build request: %w", err)
    }

    resp, err := client.Do(req)
    if err != nil {
        return User{}, fmt.Errorf("send request: %w", err)
    }
    defer resp.Body.Close()

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

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return User{}, fmt.Errorf("decode response: %w", err)
    }

    return user, nil
}

This helper owns the body for its entire lifetime. Callers receive a value rather than a live network resource, so there is no ambiguity about who must close it.

Returning *http.Response is still appropriate when callers genuinely need streaming access, headers, trailers, or protocol-specific handling. In that design, document that ownership of Body transfers to the caller.

Preserve useful error details without unbounded reads

APIs often include a small diagnostic body with non-success responses. Returning only the status can hide information that helps operators understand a failure, but reading an unlimited error body creates the same memory risk as any other unlimited read.

A bounded helper keeps the diagnostic useful:

func readErrorBody(body io.Reader) string {
    const maxErrorBody = 8 << 10 // 8 KiB

    data, err := io.ReadAll(io.LimitReader(body, maxErrorBody))
    if err != nil {
        return ""
    }
    return strings.TrimSpace(string(data))
}

Then include the text only when present:

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    detail := readErrorBody(resp.Body)
    if detail == "" {
        return fmt.Errorf("unexpected HTTP status: %s", resp.Status)
    }
    return fmt.Errorf("unexpected HTTP status: %s: %s", resp.Status, detail)
}

This deliberately reads only a bounded prefix. If the server sends more, the connection may not be reusable after the body is closed. For an error path, bounding memory and latency can be more important than preserving one pooled connection.

Also consider whether response text is safe to expose. Upstream error bodies can contain user data or implementation details, so logging or returning them should follow the application’s data-handling policy.

Understand what Close does and does not guarantee

The portable rule for callers is simple: close every response body you own.

For connection reuse, the documented Client.Do contract says that if a body is not both read to EOF and closed, the underlying RoundTripper may be unable to reuse a persistent connection. The standard Transport currently also tries to consume a conservative amount of unread body data asynchronously when Close is called.

Do not build correctness around that implementation behavior. It has a limit, and another RoundTripper can have different behavior. If your application naturally needs the complete body, read it normally and then close it. If the body is unexpectedly large or no longer useful, close it rather than draining arbitrary data solely for reuse.

Connection reuse is an optimization; bounded resource consumption is a correctness property.

Do not defer closes inside a long loop

defer runs when the surrounding function returns, not at the end of the current loop iteration. This pattern can keep many bodies open at once:

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

    // Process response.
}

Move one iteration into a helper so each defer has a short lifetime:

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

    _, err = io.Copy(io.Discard, resp.Body)
    return err
}

for _, url := range urls {
    if err := fetchOne(client, url); err != nil {
        return err
    }
}

Here io.Copy is appropriate only because this example intentionally consumes each complete response. Do not copy an unbounded body to io.Discard when the application needs a strict limit.

Reuse clients, not response bodies

An http.Client and its transport are designed to be reused. Reusing them allows the transport to maintain connection pools and avoids repeatedly constructing networking state.

A response body is the opposite: it represents one response stream and has a finite ownership lifetime. Consume what the application needs, handle read errors, and close it promptly.

Avoid creating a new client merely to avoid thinking about cleanup. A new client does not remove the requirement to close response bodies, and repeated transports can prevent useful pooling.

Know the cases that need different handling

The normal read-and-close pattern is not universal.

A 101 Switching Protocols response transfers the connection into a different protocol workflow, so generic body handling may no longer describe the application’s intent. Long-lived streaming responses such as event feeds also keep Body open intentionally until cancellation or stream termination. In those cases, ownership is still explicit, but “promptly” means when the stream’s real lifetime ends.

Likewise, HTTP/2 multiplexes streams differently from HTTP/1.x connection reuse. Do not infer HTTP/2 behavior from HTTP/1.x socket-pool intuition. The stable application rule remains to consume according to the protocol you expect and close the body when finished.

Conclusion

Treat http.Response.Body as an owned streaming resource. After Client.Do succeeds, arrange Close immediately, distinguish HTTP status failures from transport errors, and choose deliberately between complete reads, streaming decoders, and bounded reads.

Reading to EOF can help an eligible persistent connection be reused, but connection reuse should not force your program to consume unlimited or unwanted data. Clear ownership and explicit size boundaries make HTTP clients easier to reason about under both normal responses and failure conditions.