skip to content

In net/http, why is it invalid to read r.Body after ServeHTTP has returned?

level: middleimportance: should knowfreq 45%

answer

  1. the server lends, it does not give
  2. who closes the body?
  3. keep-alive means the socket moves on
  4. the pointer outlives the resource
  5. copy or decode before you return

basics

~20 s

The server owns the request body and closes it once the handler returns, then may reuse the connection for the next request. Later reads fail or race with unrelated traffic, so copy what you need out of r.Body before returning.

solid answer

~50 s

The `*http.Request` the server hands you is only valid for the dynamic extent of `ServeHTTP`. When the handler returns, the server closes `r.Body` itself — the handler is explicitly not required to — and on a keep-alive connection it goes straight on to read the next request off the same socket. A goroutine that still holds `r.Body` is therefore reading something already closed: at best it gets `http.ErrBodyReadAfterClose` or an empty result, at worst it races the server's own use of that connection. The same lifetime rule applies to the `http.ResponseWriter`. If background work needs the payload, read and decode it inside the handler — `io.ReadAll` into a `[]byte` you own, or a decoded struct — and hand the copy to the goroutine. Deciding what may run after the response is a separate question; deciding what it may *touch* is this one.

code

go · 9 lines
go
func handler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	go archive(body) // a copy, not a view over the connection
	w.WriteHeader(http.StatusAccepted)
}

go deeper

for a junior

Remember the rule as ownership: the server closes the request body when your handler returns. Read what you need with io.ReadAll or a decoder inside the handler, and never hand r.Body to a goroutine.

for a middle

Explain why: the body is a bounded view over a connection the server reuses for the next request, so a late read hits a closed body or races live traffic. Extend the same lifetime rule to the ResponseWriter.

for a senior

Show how you would catch it — the discarded io.ReadAll error, zero-length rows in the destination table, and a race-detector run over the background path — and how you would review for it in a codebase full of async handlers.

for a principal

Set the boundary convention: background work receives decoded, validated values rather than framework objects. That rule prevents this bug class outright and also keeps handlers from leaking transport concerns into domain code.

## The lifetime rule Go's HTTP server lends you three things for exactly as long as your handler is on the stack: the `http.ResponseWriter`, the `*http.Request`, and the request body hanging off it. `net/http` documents this plainly — the server closes the request body so the handler does not have to, and a `ResponseWriter` may not be used after `ServeHTTP` returns. Everything else follows from that. ## What the server actually does when you return When `ServeHTTP` returns, the server finishes the response, closes `r.Body`, and — if the connection is keep-alive and the protocol allows it — reuses that same connection for the next request. Reuse is the dangerous part. The body of an HTTP/1.1 request is not an independent stream; it is a view over the connection's reader, bounded by `Content-Length` or by chunked framing. Once the server moves on, that reader is positioned at the *next* request's bytes. So a late read is not merely useless: - it typically returns an error such as `http.ErrBodyReadAfterClose`, or an immediate EOF; - and where it does not, it is touching state the server is concurrently using, which the race detector will report if you exercise the path under `-race`. ## Why the mistake is easy to make The shape that produces it looks reasonable: ```go func handler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusAccepted) go func() { body, _ := io.ReadAll(r.Body) // wrong: handler has returned archive(body) }() } ``` The author reasoned about the *pointer* — `r` is still reachable, so surely it is still usable. But reachability is not validity: the request value survives, and the resource behind it does not. The goroutine usually records an empty payload, which is why this bug is often discovered weeks later as a table full of zero-length rows rather than as a crash. ## The fix Do the reading while the handler is still running, and pass a value the goroutine owns: ```go body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "bad body", http.StatusBadRequest) return } go archive(body) ``` The same applies to anything else derived from the connection: if a goroutine needs a header value, a path parameter or the remote address, copy the string out first. Copying a `[]byte` is enough because the slice you got from `io.ReadAll` was freshly allocated; a slice you carved out of a server-owned buffer would need a real copy. A decoded struct is usually the better hand-off anyway: it is smaller, it fails inside the handler where you can still return `400`, and it makes the background work independent of the wire format. ## Adjacent good practice Even inside the handler, reading an unbounded body is a hazard — a client can stream gigabytes into `io.ReadAll`. Wrapping the body with `http.MaxBytesReader` caps it and makes the server reject the excess. That is about resource safety rather than lifetime, but the two decisions land in the same three lines of code. And note the boundary with the other half of this topic: getting the *payload* to a background goroutine safely, as above, does not by itself make that goroutine correct. It still needs a context that is not the request's, a bound on how long it may run, and a plan for what happens if the process exits first. ## What a good answer includes Name the ownership: the server closes the body and reuses the connection. Name the symptom: empty or errored reads rather than a loud failure. Name the fix: copy or decode inside the handler. And extend the rule to the `ResponseWriter`, because the same person who reads a stale body usually also writes to a stale writer.

  • Does the handler need to close r.Body itself?
    No. For server requests `net/http` closes the request body for you when the handler returns. Closing it early is allowed and can be a way to stop reading, but the usual mistake is the opposite one — assuming it stays open afterwards. Client-side response bodies are the reverse: those you must close yourself.
  • Why does this bug usually show up as empty records rather than a panic?
    Because the late read returns an error or an immediate EOF and the goroutine ignores it — often with `io.ReadAll(r.Body)` whose error is discarded. Nothing crashes; you simply store zero-length payloads. Checking that error, and running the path under the race detector, turns a silent bug into a visible one.
  • A goroutine needs the caller's remote address and one header value. Is copying the *http.Request pointer enough?
    No. Copy the strings themselves — `r.RemoteAddr` and the specific header value — into local variables inside the handler. Holding the request pointer keeps you attached to state the server is free to reuse, and it is far larger than the two fields you actually need.

saying these in an interview costs you the question

  • Thinks the request stays valid as long as the pointer is reachable
  • Reads r.Body from a goroutine started by the handler
  • Believes the handler must close r.Body for server requests
  • Writes to the ResponseWriter after ServeHTTP returned
  • Ignores the error from io.ReadAll on the request body
  • Passes the whole *http.Request to background work