io.Pipe and io.LimitReader matter for memory
This lesson is grounded in the OOM fix we just shipped in
a real production incident.
Before that fix, every incoming Replicate webhook caused the pod to download
an entire 80 MB video into RAM before uploading it. This lesson explains
why that was the problem, and exactly how io.Pipe and
io.LimitReader solved it.
io.ReadAll as an invisible buffer
The original code in downloadfile.go was:
resp, _ := http.Get(fileURL)
fileBytes, _ := io.ReadAll(resp.Body) // ← full file in RAM
return fileBytes, nil
io.ReadAll reads from a reader until EOF and returns all the
bytes as a single []byte slice. For a small JSON response this
is harmless. For an 80 MB video it allocates 80 MB on the heap and
keeps it there until the slice is garbage-collected.
The hidden danger: Go's garbage collector doesn't free a
slice while any live variable still references it. The 80 MB stays in memory
from the moment io.ReadAll returns until after the upload
function finishes and the slice goes out of scope. With 10
concurrent webhooks that is 800 MB — more than the pod's 512 MB limit.
io.Pipe
io.Pipe() creates a synchronised in-memory channel between a
writer and a reader. No data is buffered — bytes written on
one end are immediately readable on the other end, and the writer blocks
until the reader consumes them.
// io.Pipe signature
func Pipe() (*PipeReader, *PipeWriter)
Think of it as a Unix pipe, but between two goroutines in the same process:
In the new DownloadAndUploadFile:
pr, pw := io.Pipe()
mw := multipart.NewWriter(pw)
go func() {
// goroutine A: write the multipart form into pw
for _, form := range req.buildFormData() {
mw.WriteField(form.Key, form.Value)
}
fw, _ := mw.CreatePart(h) // file part header
io.Copy(fw, resp.Body) // stream download into pw
pw.CloseWithError(mw.Close())
}()
// goroutine B (main goroutine): read from pr → upload request body
uploadReq, _ := http.NewRequestWithContext(ctx, "POST", endpoint, pr)
http.DefaultClient.Do(uploadReq) // reads from pr as it sends
Key insight: io.Pipe doesn't move data —
it connects two streams. The network layer handles chunked transfer
automatically. The video bytes flow from Replicate's server directly into
the outgoing POST body, touching the heap only as small network buffers
(~8–32 KB).
io.LimitReader: the safety valve
Even in the streaming path, a malformed response could theoretically stream
forever. io.LimitReader wraps any reader and returns EOF after
at most n bytes:
// io.LimitReader signature
func LimitReader(r Reader, n int64) Reader
It is just a struct holding a counter:
type limitedReader struct {
r Reader
n int64 // remaining bytes
}
func (l *limitedReader) Read(p []byte) (n int, err error) {
if l.n <= 0 {
return 0, io.EOF
}
if int64(len(p)) > l.n {
p = p[:l.n] // shrink the slice to remaining limit
}
n, err = l.r.Read(p)
l.n -= int64(n)
return
}
In downloadfile.go and download_raw_url.go the fix
was:
const maxDownloadBytes int64 = 2 * 1024 * 1024 * 1024 // 2 GB
fileBytes, err := io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes))
if int64(len(fileBytes)) == maxDownloadBytes {
return nil, apierror.Error("0500", fmt.Errorf("file exceeds maximum allowed size"))
}
Why check len == maxDownloadBytes? Because
io.LimitReader returns EOF silently at the limit. Without the
length check, a 2 GB file would be silently truncated and uploaded as a
corrupt video. The explicit error makes the failure visible.
| Function | What it does | Memory behaviour | Use when |
|---|---|---|---|
io.ReadAll |
Read entire reader into []byte |
Allocates full content size on the heap | Content is small, or you need the bytes for local processing (e.g. image watermarking) |
io.Pipe |
Connect a writer goroutine to a reader goroutine | ~32 bytes coordination buffer; data never lands on heap | Bytes flow through without modification (download → upload, decode → encode) |
io.LimitReader |
Cap how many bytes a reader will deliver | No allocation; just a counter decorator | Any time you accept untrusted or unbounded input; always pair with io.ReadAll |
io.Copy |
Copy from a reader to a writer using a reusable 32 KB buffer | 32 KB stack buffer; constant regardless of file size | Whenever you would otherwise loop over Read manually |
When you use io.Pipe, the goroutine writing to pw
must always call pw.CloseWithError — even on
success. If it exits without closing, the reader blocks forever.
go func() {
var err error
// ... do work ...
pw.CloseWithError(err) // nil = EOF (success), non-nil = error
}()
The pattern pw.CloseWithError(mw.Close()) used in our code is
idiomatic: mw.Close() writes the multipart boundary terminator
and returns any write error; that error (or nil) is forwarded to the pipe
reader.
Goroutine leak trap: If http.DefaultClient.Do
returns an error (e.g. upload server unreachable), the pipe reader is closed.
The writer's io.Copy will then get a broken pipe error on its
next write, which unblocks it. Always check that your writer handles write
errors and exits — which is why every write in the goroutine returns early on
error.
Streaming via io.Pipe is the wrong choice when:
This is exactly why the reserved (pre-signed GCS) upload path in a production incident was left on the buffered path — GCS pre-signed POST requires specific field ordering that's easier to guarantee when you have the full bytes.
Q1. You download a 50 MB video with io.ReadAll then upload it. Peak heap usage for this operation is approximately:
Q2. What happens if the writer goroutine panics without calling pw.CloseWithError?
Q3. io.LimitReader(resp.Body, 1024) is applied to a 5000-byte response. How many bytes does io.ReadAll return?
Q4. You need to download an image, apply a watermark (in memory), then upload it. Which approach is correct?
io.Pipe is built on. Then read the
io package godoc
— it is short, precise, and worth reading completely.
io.TeeReader? Want to trace exactly what happens to bytes during client.Do(req)? Type a question in the chat.