Backend Systems · Request Handling
Reading Request Bodies Without Holding Them in Memory
How to consume a request body as a stream, what backpressure means at the socket, and where size limits, decompression, and multipart parsing go wrong.
A handler receives a reader, not a byte slice. That distinction is the whole subject: the body may be arbitrarily large, it arrives at the client’s pace, and the amount the server holds at once is a decision the handler makes rather than a property of the request. The default decision in most frameworks is to read everything into memory before invoking the handler, which is convenient and turns the maximum request size into a memory limit.
Streaming the body instead means processing bytes as they arrive. It costs a more complicated handler and buys a bound on memory that does not depend on what clients send.
Two ways a body arrives, one interface
With Content-Length, the client states the size and the server knows when to stop. Without it, on a request, the body is chunked and each chunk carries its own size. A handler that reads until end of stream does not need to know which case applies, but anything that pre-allocates does: Content-Length can be used to size a buffer or reject a request early, and a chunked request offers no such number until it ends.
Chunked bodies also mean the declared size cannot be trusted as a limit even when it exists, because the sender can lie, and the server’s obligation is to count bytes as they arrive rather than to believe the header. A Content-Length of 100 with a body of a million bytes is a framing error, but the bytes will reach the socket before the parser notices, so the limit must be enforced on the read path.
Backpressure is the mechanism, not a feature
When a handler stops reading, the socket’s receive buffer fills. Once it is full, the kernel advertises a zero window and the sender’s TCP stack stops transmitting. That is backpressure: the client’s write blocks, and no intermediary has to be told anything.
This gives streaming a property that buffering cannot have. A handler that reads at the rate it can process, for example because it is writing to a database that is applying its own flow control, propagates that rate all the way back to the client. Memory stays flat, and the cost of a slow consumer is time rather than space.
The corollary is that a handler which reads the whole body into memory removes the backpressure. The socket drains as fast as the network delivers, the client finishes its upload, and the server then holds the entire body while it does its work. For a 10 MiB upload that is 10 MiB per concurrent request; the number of concurrent requests becomes the memory limit, and the failure is an out-of-memory kill rather than a rejected request.
Reading in fixed-size pieces and doing the work per piece keeps both properties. A 32 KiB buffer, which is the default copy size in several standard libraries, is enough to keep the syscall count reasonable while remaining negligible per connection.
Where the bytes should go
Three destinations cover most cases, and each has a different failure mode.
The first is an external sink: object storage, a message broker, another service. The handler copies from the request body to the sink and never holds more than the copy buffer. The complication is that the copy is not atomic. A client that disconnects halfway leaves a partially written object, so the destination needs a staging area and a commit step, or the design needs an identifier that only becomes visible after the copy completes.
The second is a local file, written under a temporary name and renamed into place after the copy finishes and the data is flushed. Rename is atomic within a filesystem, so readers either see the old file or the complete new one. Without the rename, a reader can observe a truncated file, and without a flush the operating system may hold the data in page cache when the process is killed.
The third is incremental computation: a hash, a count, a parse. A body that is hashed as it arrives needs no storage at all, and io.MultiWriter style composition lets one pass feed several consumers, which matters because the stream cannot be rewound. Any design that needs a second pass over the body has to have kept it, which reintroduces the memory cost the streaming was meant to avoid.
func upload(w http.ResponseWriter, r *http.Request) {
// 8 MiB cap; the reader returns an error once exceeded
body := http.MaxBytesReader(w, r.Body, 8<<20)
defer body.Close()
tmp, err := os.CreateTemp("", "upload-*")
if err != nil { http.Error(w, "storage", 500); return }
name := tmp.Name()
defer os.Remove(name) // no-op after successful rename
h := sha256.New()
n, err := io.Copy(io.MultiWriter(tmp, h), body)
if err != nil { http.Error(w, "too large or unreadable", 413); return }
if err := tmp.Sync(); err != nil { http.Error(w, "flush", 500); return }
tmp.Close()
if err := os.Rename(name, filepath.Join(os.TempDir(), fmt.Sprintf("final-%x", h.Sum(nil)))); err != nil {
http.Error(w, "commit", 500); return
}
w.WriteHeader(201)
fmt.Fprintf(w, "%d bytes, %x", n, h.Sum(nil))
}
Refusing before the bytes arrive
A size limit enforced only after reading is a limit on storage, not on bandwidth. The body has already crossed the network by the time the handler rejects it.
Expect: 100-continue gives the server a chance to answer before the body is sent: the client sends headers only, waits briefly for a 100 Continue, and proceeds if it arrives or if the wait expires. A server that will reject the request on its headers, because the declared length exceeds a limit or the credentials are missing, can send 413 or 401 directly and the body never leaves the client. This only works if the check happens before the body is read, which is an ordering constraint on the layers around the handler; middleware chain ordering covers where that check belongs.
Limits also need to apply to the decoded form, not the encoded one. A compressed request body expands, and a compressed stream can expand by several orders of magnitude from a small input. Counting bytes on the wire while decompressing gives a limit that the attacker chooses. The counter belongs after decompression, and the decompressor itself needs a bound on total output.
Multipart parsing costs more than it appears
multipart/form-data wraps each field and file in a part with its own headers and body, separated by a boundary declared in the Content-Type parameter. RFC 7578 profiles the general MIME multipart rules of RFC 2046 for form submission; the boundary is at most 70 characters and must not appear inside any part’s content, which is why senders choose long random strings.
Parsers typically expose each part as a reader, and the temptation is to read every file part into memory before validating it. The parts arrive in the order the client sent them, so a parser that also enforces a per-part limit can stop as soon as one part exceeds it, but it cannot skip ahead: the boundary may contain the interesting data, so every byte still has to be scanned. A form with a large text field before a small file field costs the full size of that field in parse time even if the file is tiny.
Part headers are attacker-controlled and must not be used as filesystem names without sanitisation. The filename parameter is a suggestion; a value containing path separators or .. must not be permitted to influence where the server writes.
Bodies that are never read
A handler that answers without consuming the body leaves bytes in the socket. On a persistent connection those bytes are the beginning of the next request, so the server cannot simply reuse it. Implementations handle this by draining a bounded amount and then closing the connection: the response is delivered, and the cost is a connection teardown rather than corrupt framing. A handler that writes an early rejection and knows the body is large should prefer to close explicitly, since draining a large body to preserve reuse spends exactly the bandwidth the rejection was meant to save.
A body that arrives slowly is also a slow-connection attack on the read path, indistinguishable in effect from a slow header attack once the headers are complete. Read deadlines covering the body, described with the header-level defences in header limits and slow connections, apply here too, and they must be absolute deadlines rather than per-read resets or the attacker extends them one byte at a time.
References
- RFC 9110: HTTP Semantics — IETF
- RFC 7578: Returning Values from Forms: multipart/form-data — IETF
- Go standard library — net/http MaxBytesReader — Go project documentation