Read the Request Body Before WriteHeader on HTTP/1
This echo handler passes every test I would think to write for it, and still truncates responses in production. It sets the status, then copies the request body back to the client:
func echo(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
buf := make([]byte, 1024)
for {
n, err := r.Body.Read(buf)
if n > 0 {
w.Write(buf[:n])
}
if err != nil {
log.Printf("echo finished: %v", err)
return
}
}
}
Post 100 bytes, get 100 bytes back. Post 1000, fine. Post 100,000 bytes and the client receives exactly 3072 of them: a clean response, no error, and the server log says http: invalid Read on closed Body. That number is not random and neither is the message, so it is worth taking apart.
The docs say "may be unavailable", which undersells it
The ResponseWriter documentation has a paragraph on this:
- For HTTP/1.x, handlers should read any needed request body data before writing the response. Once the headers have been flushed, the request body may be unavailable.
- For HTTP/2, the server permits concurrent reading and writing, though "not all clients" will cope with it.
"May be unavailable" is a specific, deterministic behaviour in the server. The interesting part is when it triggers, because it is not when you call WriteHeader.
WriteHeader doesn't send anything
Calling WriteHeader only records the status code. The response has a 2048-byte buffer in front of the connection, and the headers go out when one of three things happens:
- you call
Flush; - the buffer fills up;
- the handler returns.
Only at that moment does the server look at the request body.
What the server does to an unfinished body
At the point of writing the real headers, if the request had a body and the handler has not finished with it, the server does this (paraphrasing chunkWriter.writeHeader in net/http/server.go):
- If the unread part of the body is small, it reads and throws away the rest, then closes the body. The limit is a constant in the source, currently 256 KiB.
- If more than that is left unread, it does not bother. It marks the response
Connection: closeand drops the connection once the reply is done.
Why that is reasonable
The reasoning is sound. An HTTP/1 connection carries one exchange at a time, and the server cannot safely start the next request until it has consumed the previous body.
Many clients also do not read the response until they have finished sending, so a server that streams a reply while the client is still writing can deadlock both ends. Draining a small body is a cheap way to keep the connection reusable; refusing to drain a huge one is the guard against being made to read gigabytes for nothing.
So that is where 3072 comes from
Here is how my echo handler died, step by step:
- It reads in 1 KiB chunks and writes each one back.
- The third write overflows the 2 KiB buffer, so the headers flush.
- The server discards the remaining ~97 KB and closes the body.
- The fourth
ReadreturnsErrBodyReadAfterClose.
The client sees a well-formed chunked response that simply stops.
Measured: small works, medium breaks, large works again
I ran the handler above against httptest.NewServer (HTTP/1.1, Go 1.22.2), posting bodies of various sizes:
| Body size | Bytes echoed | Handler error | Connection |
|---|---|---|---|
| 100 | 100 | EOF | reused |
| 3,000 | 3,000 | EOF | reused |
| 100,000 | 3,072 | read on closed Body | reused |
| 300,000 | 300,000 | EOF | closed |
Actually, that last row is the funny one. The 300,000 byte body works, because it is over the drain limit: the server declines to discard it, leaves the body open, and marks the connection to close.
So the handler is correct for tiny payloads, broken for medium ones, and correct again for large ones, at the price of a new connection. The 3,000 byte row survives only because the whole body had already been consumed by the time the headers flushed. That is exactly the kind of bug that turns up in production and never in a unit test.
Fix one: read first, then write
If you can buffer, do it. Read the body (with a size limit, see below), then write the response. Most handlers already do this, and it is the only approach that works with every HTTP/1 client.
func handler(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
data, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "bad body", http.StatusRequestEntityTooLarge)
return
}
// only now start writing
w.WriteHeader(http.StatusOK)
w.Write(data)
}
Note the order: the error check happens before any write. Once headers have gone out you cannot change the status to 413.
Fix two: opt in to full duplex
Sometimes you really do want to stream both ways, say a transformation that emits output while the input is still arriving. Since Go 1.21, http.ResponseController has EnableFullDuplex for exactly this. It switches off the drain-and-close behaviour for that request:
func stream(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
if err := rc.EnableFullDuplex(); err != nil {
http.Error(w, "full duplex unsupported", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
if err := rc.Flush(); err != nil {
return
}
buf := make([]byte, 1024)
for {
n, err := r.Body.Read(buf)
if n > 0 {
if _, werr := w.Write(buf[:n]); werr != nil {
return
}
rc.Flush()
}
if err != nil {
return
}
}
}
Same test, same sizes: all of 100, 3,000 and 300,000 bytes came back complete, and the 300,000 byte case no longer forced the connection closed.
Call it before you write anything, and check the error, since it is the handler's way of finding out whether the underlying connection supports it.
The catch is on the other side of the wire
Full duplex is your side of the deal. The client has to cooperate, and that is where HTTP/1 gets awkward.
- Go's own client reads the response while it is still sending the body, so my test worked.
- A client that writes the whole request before it starts reading the response will happily fill its send buffer while your handler blocks writing a reply nobody is reading. Both sides then wait on each other.
Before building anything on this, be sure what your clients actually do. For something you control, that is easy; for browsers and arbitrary tools it is often not.
Quick detour on HTTP/2: it does not have the problem in the same form, since streams are independent frames on one connection. The server lets handlers read and write concurrently there without any opt-in, though the documentation still hedges about client support.
Rules I now follow on HTTP/1
- Do not write, flush or call
WriteHeaderand then expect to read the body on HTTP/1, unless you have calledEnableFullDuplex. - Do not treat "works with a small test payload" as evidence. The 2 KiB buffer and the 256 KiB drain limit are the two thresholds that change behaviour.
- If a client reports truncated responses with no transport error, look for a handler that starts writing before it has finished reading.
The last one is the diagnosis I would have wanted an hour earlier: truncated echo output plus ErrBodyReadAfterClose in the log almost always means a write got ahead of a read.