Example: HTTP Client Stream Large Responses
Large HTTP responses should usually be streamed, not loaded into memory.
The simplest mistake is:
For a small API response, that is fine. For a large download, it makes memory usage proportional to the response size.
This example shows how to stream a response directly to a file while enforcing a maximum size.
Quick Example
Stream the response body directly to a file:
The important part is:
The response is consumed incrementally instead of creating a byte slice containing the entire response.
For production code, also set an explicit request timeout and handle file cleanup carefully.
Why Stream the Response?
Suppose a server returns a 2 GB file.
With:
the application must hold the response data in memory.
With:
memory usage stays roughly independent of the response size.
The data flow becomes:
There is no need to materialize the entire response.
This matters for:
- large file downloads
- backups
- database exports
- object storage
- compressed archives
- media files
- large API responses
io.Copy Is More Than a 32 KiB Loop
io.Copy is not simply equivalent to writing your own fixed-size Read loop.
It first checks whether the source implements io.WriterTo. If not, it checks whether the destination implements io.ReaderFrom:
This allows concrete types to provide more efficient transfer paths. The io package documents this dispatch explicitly.
For example, *os.File implements io.ReaderFrom. On Linux, its implementation can use platform-specific mechanisms such as copy_file_range and splice when the source and destination support them, reducing userspace copies.
Do not, however, assume that every HTTP download automatically becomes a Linux zero-copy transfer. net/http wraps the underlying network connection, and the zero-copy path depends on the concrete Reader exposed to os.File.ReadFrom.
The useful engineering rule is simpler:
Prefer
io.Copyover a hand-written buffer loop unless you actually need per-chunk logic.
Let the source and destination choose their optimized path.
Enforce a Maximum Response Size
Streaming prevents a large response from consuming all available memory, but it does not prevent an unexpectedly large file from consuming disk space.
Use io.LimitReader when the application has a maximum acceptable size.
The +1 is important.
If the limit is exactly 100 MiB, reading only:
cannot distinguish:
from:
Reading one extra byte lets the application detect an oversized response.
A complete helper might look like this:
The limit is enforced while reading, not after the complete response has already been written.
Content-Length Is Only an Early Check
If the server provides a useful Content-Length, it can reject obviously oversized responses before downloading them:
But this is only an early rejection.
A response may have:
when its length is unknown.
The application should still enforce the limit while reading:
Do not make the application's size guarantee depend on Content-Length.
Compression makes this distinction even more important.
When net/http.Transport automatically requests gzip and transparently decompresses the response, the resulting Response has:
and resp.Body yields the decompressed bytes. This is deliberate behavior in the standard library.
Therefore, Content-Length is not an application-level decompressed-size limit.
For a hard payload limit, enforce the bound on the stream you actually consume.
Write to a Temporary File First
For downloads that must not leave a partial destination file, write to a temporary file and rename it only after the download succeeds.
Create the temporary file in the destination directory:
This matters because os.Rename generally requires the source and destination to be on the same filesystem. Creating the temporary file in the system temporary directory can therefore cause a cross-device rename failure when dst is on another mount.
A complete implementation:
The important sequence is:
This prevents a failed download from replacing the existing destination with a partial file.
The cleanup defer intentionally ignores a second Close or Remove error after an earlier failure. If every filesystem error must be reported, use an explicit cleanup path or a named-return pattern instead.
For downloads where durability across sudden power loss matters, Sync is relevant. For ordinary caching, it may be unnecessary overhead.
Reuse the HTTP Connection After an Early Exit
If the application stops reading a response early, it may leave unread response data behind.
For example, after detecting an oversized response:
the body may still contain additional data.
If the remaining response is small, an application can make a bounded best-effort drain before returning:
Then resp.Body.Close() is still required.
The important point is that draining should itself be bounded. Never turn an oversized-response error path into an attempt to consume an arbitrarily large response.
Modern Go HTTP/1 clients also perform limited automatic draining when Response.Body.Close is called, so explicit draining is not universally required for connection reuse. An explicit bounded drain is useful when the application wants a predictable, best-effort policy rather than relying entirely on the transport's internal behavior.
Do Not Use io.ReadAll Just to Check the Size
This is tempting:
It defeats the purpose.
The application has already allocated memory proportional to the response size before checking the limit.
If the requirement is:
Never allow this response to consume more than approximately
maxSizebytes of application-level buffering.
then enforce the bound during the read.
Stream Through Multiple Destinations
Sometimes the response needs to be written to more than one destination.
For example:
io.MultiWriter writes each chunk to its destinations sequentially.
If any destination blocks, the copy blocks.
If one destination returns an error, the copy stops.
This is useful for operations such as:
It does not create independent asynchronous consumers.
If one destination is slow, the HTTP response consumption is also slowed.
Stream to a Consumer with io.Pipe
Sometimes the response should not be stored locally at all.
For example, an application may download data and immediately feed it into another processing pipeline.
A pipe can connect the response reader to another component:
io.Pipe provides synchronous streaming between a writer and reader. It does not provide an application-level buffer that can absorb an arbitrarily large response.
The producer can therefore be slowed by the consumer, providing backpressure.
This is useful when the downstream operation should process data as it arrives rather than waiting for the complete response.
Lifecycle management matters: if the consumer stops early, the producer must have a way to stop as well. In more complex pipelines, use context cancellation and make sure every goroutine has a defined exit path.
JSON Responses Can Also Be Streamed
Streaming is not limited to files.
For a large JSON response, use json.Decoder rather than loading the entire response first:
json.Decoder is streaming, but it still maintains internal buffering, and decoding a very large individual JSON value can require substantial memory.
Putting io.LimitReader in front of the decoder provides an overall input bound:
The limit protects the amount of JSON input consumed. It does not guarantee that every individual decoded value has constant memory usage.
For protocols containing extremely large individual objects or strings, impose additional application-level bounds on those values.
Compression Changes the Size Problem
HTTP clients commonly negotiate gzip automatically.
That creates two different sizes:
A compressed response can be much smaller on the network than after decompression.
When net/http performs automatic decompression, resp.Body exposes the decompressed stream and the response metadata is adjusted accordingly. In particular, ContentLength is set to -1 and the corresponding Content-Length header is removed.
Therefore:
limits the data actually exposed to the application.
If the application must protect itself against decompression expansion, enforce the limit at the point where decompressed data enters the application.
For particularly sensitive endpoints, consider the relationship between:
- compressed response size
- decompressed size
- CPU cost
- disk usage
- downstream processing limits
A network-level size limit and an application-level payload limit are different controls.
Streaming Does Not Mean Unlimited
A streaming implementation can still fail badly if it has no bounds.
Consider:
This protects memory usage, but a malicious or broken server could send data indefinitely.
The application may need several independent limits:
For example:
The context limits the lifetime of the request.
io.LimitReader limits the amount of response data consumed.
They solve different problems.
Client.Timeout and Streaming
An HTTP client timeout also applies while reading the response body.
For example:
This is useful for ordinary downloads with a known upper bound on total request duration.
For long-lived streaming responses, however, an overall timeout may be the wrong policy.
A stream that is expected to remain open for hours should not use a two-minute total timeout simply because ordinary API requests do.
Use a request context or a more appropriate transport-level timeout policy when the application needs different semantics.
The important distinction is:
Do not treat them as interchangeable.
A Manual Read Loop
io.Copy is usually the right abstraction.
A manual loop can make the streaming behavior explicit:
The important rule is:
Process
n > 0before handlingerr.
A reader is allowed to return data and an error in the same call.
The 32 KiB buffer here is illustrative. io.Copy may use different buffering or a WriterTo / ReaderFrom fast path.
Use a manual loop when you need per-chunk logic such as:
- progress reporting
- checksums
- rate limiting
- custom framing
- application-specific accounting
Otherwise, prefer io.Copy.
What Happens When the Response Fails?
A streaming download can fail after receiving a perfectly valid beginning of the response.
For example:
The application now has a partial file.
That is why the temporary-file pattern is often preferable:
rather than:
If the protocol supports resumable downloads, the application can instead preserve partial state deliberately and use mechanisms such as HTTP range requests.
That is a different design from treating a partial download as a successful file.
Choosing the Implementation
Small response
Use:
when the response is known to be small and bounded.
Examples:
- small JSON API responses
- configuration documents
- short text responses
Large response
Use:
when the response should be streamed.
Large response with a hard size limit
Use:
when the application must reject oversized responses.
Durable download
Use:
when a partial destination file is unacceptable.
Large JSON stream
Use:
with an appropriately bounded input stream when the protocol permits incremental decoding.
Code Review
When reviewing large-response handling, ask:
Memory
- Is
io.ReadAllbeing used on an unbounded response? - Is the maximum in-memory payload known?
Copy path
- Is
io.Copybeing used instead of an unnecessary manual buffer loop? - Could
WriterToorReaderFromprovide a more efficient path?
Size
- Is
Content-Lengthbeing treated as the only size check? - Is the actual stream bounded with
io.LimitReaderwhen necessary? - Is the
maxSize+1pattern used when oversized responses must be detected?
Files
- Is the temporary file created in the destination directory?
- Can a failed download leave a partial destination?
- Should a temporary file be used?
- Are
Sync,Close, andRenameerrors handled according to the durability requirements?
Timeouts
- Can the server keep the connection open indefinitely?
- Is the request context appropriate for the expected download duration?
- Is
Client.Timeoutbeing confused with a body-size limit?
Compression
- Is the application limiting the data it actually processes?
- Does automatic decompression change the meaning of the available response metadata?
Connection reuse
- If the application stops reading early, does it have a bounded drain policy where useful?
- Is an unbounded drain being avoided?
Concurrency
- If
io.Pipeis used, can every producer and consumer exit? - If multiple writers are used, can one slow destination stall the entire pipeline?
Partial results
- What happens if the connection fails halfway through?
- Is a partial file distinguishable from a completed file?
Engineering Rule
For large HTTP responses, think in terms of streams and bounds, not byte slices.
The core pattern is simple:
But production correctness comes from the surrounding decisions:
- bound the data you accept
- bound the request lifetime
- use
io.Copyso the standard library can select an appropriate copy path - handle partial results
- close resources explicitly
- create temporary files on the destination filesystem
- use temporary files when necessary
- understand decompression
- preserve backpressure
- avoid loading large responses into memory
For more production-oriented Go HTTP patterns, see the HTTP Client examples on gobase.net.