Errors from a Go JSON endpoint arrive as 200 and the server logs a superfluous WriteHeader call. Why?
answer
- the status left before the failure did
- encoding to the wire commits you early
- the log line names the second WriteHeader
- buffer the body, then commit to a status
basics
~20 sThe handler encoded straight to the ResponseWriter, so its first byte sent an implicit 200. The later http.Error could not change a status already on the wire, so net/http logged the superfluous call and appended the message to the partial body.
solid answer
~40 sThis is the signature of a handler that streams its response and only then discovers a failure. `json.NewEncoder(w).Encode(v)` writes directly to the `http.ResponseWriter`; its first byte triggers `WriteHeader(http.StatusOK)`, which puts a 200 and the whole header map on the connection. When `Encode` then fails halfway — or a downstream check fails after the first write — the handler's `http.Error(w, msg, 500)` calls `WriteHeader` a second time. `net/http` cannot retract a status line, so it drops the call, logs `http: superfluous response.WriteHeader call from ...` with the file and line of the offending call to the server's `ErrorLog`, and appends the error text to the half-written body. The fix is to stop committing before you know the outcome: marshal into memory, and only then set headers, write the status and write the body once.
code
go · 8 linesfunc evaluateFlags(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
result := evaluate(r.URL.Query().Get("user"))
if err := json.NewEncoder(w).Encode(result); err != nil {
// Encode already wrote bytes, so the 200 is gone.
http.Error(w, "evaluation failed", http.StatusInternalServerError)
}
}go deeper
Know the one fact that explains the whole bug: the first byte written to an http.ResponseWriter sends a 200 unless you called WriteHeader first, and the status cannot be changed afterwards.
Explain why the second WriteHeader is dropped rather than honoured, what the log line contains, and how buffering the body with json.Marshal moves the decision before the commit point.
Diagnose from the two symptoms together, name the first write in the handler, restructure to decide-then-commit, and say what you do on genuinely streaming endpoints where buffering is not affordable.
Weigh the real cost: monitoring has been counting these failures as successes. Decide where buffering is affordable, make one shared write path the default, and define what a truncated streamed response means in the API contract.
### The report and the log line Two symptoms arrive together. A client engineer files a bug saying that failures from an endpoint come back as HTTP 200 with a body that is not valid JSON. The server log, meanwhile, carries lines like: ```text http: superfluous response.WriteHeader call from main.evaluateFlags (flags.go:38) ``` That message is produced by `net/http` itself whenever a handler calls `WriteHeader` after a status has already been sent, and it names the file and line of the *second* call — which is the fastest pointer to the bug you will get. It is written to `http.Server.ErrorLog` if you configured one, and to the standard `log` package logger if you did not, which is why it is easy to lose in a service that routes logs elsewhere. ### The mechanism `http.ResponseWriter.Write` calls `WriteHeader(http.StatusOK)` when no status has been written yet. A status line, once written to the connection, is gone: HTTP has no way to say "ignore the 200 I sent, here is a 500". So a second `WriteHeader` has exactly one sensible behaviour — ignore it and complain — and that is what the server does. The handler that produces this looks completely reasonable: ```go func evaluateFlags(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") result := evaluate(r.URL.Query().Get("user")) if err := json.NewEncoder(w).Encode(result); err != nil { http.Error(w, "evaluation failed", http.StatusInternalServerError) } } ``` `Encode` writes as it walks the value. By the time it returns an error — a field whose type cannot be marshalled, a `MarshalJSON` that failed, a value graph that turned out to be cyclic — some bytes have already gone out, the 200 with them. `http.Error` then tries to set Content-Type (too late), Content-Length (too late) and the status (too late), and writes its message onto the end of a truncated JSON document. The client sees 200 and a body that fails to parse. ### The fix: decide, then commit Serialise into memory first, so nothing is written until the outcome is known: ```go func evaluateFlags(w http.ResponseWriter, r *http.Request) { body, err := json.Marshal(evaluate(r.URL.Query().Get("user"))) if err != nil { http.Error(w, "evaluation failed", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(body) } ``` Now the only failure that can happen after the status is a network write error, and that is genuinely unreportable — you log it and move on. `json.Marshal` (or `Encode` into a `bytes.Buffer`) costs one copy of the response in memory per in-flight request, which for a feature-flag payload or any ordinary API response is nothing. The rule generalises: **do not write a byte until you know the status you want.** ### When you cannot buffer Some responses genuinely must stream — a large export, a long report. There you accept the tradeoff explicitly rather than pretending you can still send a 500: - Do every check that can fail *before* the first write: authorisation, argument validation, the first row of the query. - If a stream fails midway, the honest options are to log the failure and abandon the response, or to carry the failure in a trailer for clients that read them. Panicking with `http.ErrAbortHandler` is the documented way to abandon a response deliberately: the server closes the connection without logging a stack trace, and the client sees a truncated body rather than a lie. - Document for the caller that a 200 on a streamed endpoint means "the request was accepted and the stream started", not "the whole payload is correct" — a truncated body is the failure signal. ### Other ways the same log line appears The superfluous-`WriteHeader` warning shows up whenever a status is written twice, and the second most common cause is a missing `return`: ```go if !ok { http.Error(w, "forbidden", http.StatusForbidden) // missing return } w.WriteHeader(http.StatusOK) // dropped, and logged ``` Both variants are found the same way: read the handler forwards and mark the first thing that writes. Everything after it that touches headers or status is dead. ### What to check when you are handed this bug 1. **Read the log line's file and line** — it names the second `WriteHeader`, not the first write. 2. **Find the first write in that handler.** Usually an encoder, a template execution, or an `io.Copy` from an upstream response. 3. **Ask whether the outcome was knowable before that write.** Almost always yes, and then buffering is the fix. 4. **Check the error branches for a missing `return`.** 5. **Confirm the client-facing contract.** If a monitor or dashboard has been treating those 200s as successes, its error rate has been wrong for as long as the bug has existed — that, not the log noise, is usually the expensive part.
- Once bytes have gone out, is there any way to signal failure to the client?Not with a status code. You can abandon the response — panicking with `http.ErrAbortHandler` is the documented way, and the server closes the connection without printing a stack trace — so the client sees a truncated body instead of a false success. Trailers are the other option, for clients that actually read them.
- What does buffering the whole response cost?One copy of the body in memory per in-flight request, plus the loss of time-to-first-byte on large payloads. For ordinary API responses that is negligible and worth it. For a large export it is not, and there you keep streaming and accept that a mid-stream failure can only be signalled by truncation.
- Where does the superfluous WriteHeader message actually go?To `http.Server.ErrorLog` if you set one, otherwise to the standard `log` package logger. It includes the file and line of the second `WriteHeader` call, so it points straight at the offending branch — worth wiring ErrorLog into your structured logger so the line is not lost.
saying these in an interview costs you the question
- Thinks the later WriteHeader overrides the earlier status
- Blames the client or a proxy for the 200
- Treats the superfluous WriteHeader line as harmless noise
- Assumes net/http buffers the whole response until the handler returns
- Proposes retrying the write instead of restructuring the handler