Lesson 1 of ?  ·  Go I/O Primitives  ·  media-svc context

Why 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.

1. The problem: 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.

BEFORE (io.ReadAll path) Replicate CDN media-svc pod (512 MB limit) upload-svc ───────────── ──────────────────────────── ──────────────── [80 MB video] ──GET──▶ fileBytes []byte (80 MB) ──POST──▶ [stored] ↑ lives in RAM the whole time DownloadFile returns → caller uses it → UploadFile uses it only freed after UploadFile returns 10 concurrent webhooks = 10 × 80 MB = 800 MB → OOM kill

2. The fix: connect two streams with 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:

io.Pipe internals goroutine A (writer) goroutine B (reader) ───────────────────── ────────────────────── pw.Write(chunk) ────────▶ pr.Read(buf) │ │ └── blocks until B reads ─────┘ No heap allocation for the in-flight data. Only one small internal sync buffer (~32 bytes) for coordination.

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
AFTER (io.Pipe streaming path) Replicate CDN media-svc pod upload-svc ───────────── ────────────── ──────────────── pw ──▶ pr [80 MB video] ──GET──▶ chunk ──▶ chunk ──POST──▶ [stored] (only one chunk in RAM at a time) Peak memory per webhook: ~32 KB (HTTP buffers) 10 concurrent webhooks = 10 × 32 KB = 320 KB

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).

3. 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.

4. How they compare

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

5. The goroutine lifetime rule

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.

6. When not to stream

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.


Quick check — test your understanding

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?

Primary source: Read the official Go blog post Go Concurrency Patterns: Pipelines and cancellation — it explains the goroutine-pipeline model that io.Pipe is built on. Then read the io package godoc — it is short, precise, and worth reading completely.
💬 Ask your teacher anything. Not sure why the pipe writer blocks? Unsure when to prefer io.TeeReader? Want to trace exactly what happens to bytes during client.Do(req)? Type a question in the chat.