skip to content

Why does a net/http middleware setting a response header after next.ServeHTTP have no effect?

level: middleimportance: should knowfreq 46%

answer

  1. Header() is only a staging map
  2. something serialises it exactly once
  3. the first Write implies status 200
  4. the inner handler already sent the head
  5. modify inbound, observe outbound

basics

~20 s

By the time next.ServeHTTP returns, the inner handler has already written the status line and headers, so the header map has been serialised and later edits are ignored. Anything that must reach the client has to be set before that call.

solid answer

~40 s

`w.Header()` is only a map that `net/http` reads once, at the moment the response head is written -- the first `w.WriteHeader(code)` call, or implicitly with status 200 on the first `w.Write`. The inner handler almost always does one of those, so once `next.ServeHTTP(w, r)` has returned, the head is already on the wire and mutating the map changes nothing. A second `WriteHeader` does not help either: it is ignored, and the server logs a "superfluous response.WriteHeader call" line through `Server.ErrorLog`. The practical rule is that a wrapper may **modify** the response only on the way in and may only **observe** on the way out. Anything you need for the outbound side -- a start time, the request path -- capture it before calling `next.ServeHTTP` into a local variable of the closure.

code

go · 8 lines
go
func WithBuildHeader(build string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			next.ServeHTTP(w, r)
			w.Header().Set("X-Build", build) // too late: head already sent
		})
	}
}

go deeper

for a junior

Remember the rule: set response headers before calling next.ServeHTTP, never after. After that call, the response has usually already gone out.

for a middle

Explain the mechanism: Header() is a staging map that net/http serialises once, at the first WriteHeader or the first Write, and the implicit 200 on that first Write is what catches people out.

for a senior

Diagnose it in the wild: a header missing only on paths where the handler wrote a body, superfluous WriteHeader lines in the error log, and the discipline of capturing on the way in so the outbound side only ever observes.

for a principal

Decide how far the shared package goes. Letting wrappers alter a response after the handler ran means intercepting writes, which costs streaming and buffering; ruling it out keeps every service's response path simple and predictable.

## What w.Header() actually is `http.ResponseWriter` exposes three methods: `Header() http.Header`, `Write([]byte) (int, error)` and `WriteHeader(statusCode int)`. `Header()` returns a plain `map[string][]string`. Writing into it does not send anything; it only stages values that the server will serialise **later**, at one specific moment. That moment is the first write of the response head, and it happens on whichever comes first: - an explicit `w.WriteHeader(code)` call, or - the first `w.Write(...)`, which implicitly calls `WriteHeader(http.StatusOK)` for you. After that instant, the status line and the header block have been handed to the connection's buffer and, in practice, to the client. The map is still there and still writable -- Go will not stop you -- but nobody reads it again. ## Why the middleware position matters so much A wrapper's post-`next` code runs *after* the inner handler has returned. A handler that returned without writing anything at all is rare; the overwhelmingly common case is that it wrote a status, headers and a body. So the sequence is: 1. Your wrapper's pre-`next` code runs. The header map is still unread. Anything set here will be sent. 2. `next.ServeHTTP(w, r)` runs the rest of the chain and the final handler, which writes the head. 3. Your post-`next` code runs. The head is gone. `w.Header().Set(...)` here is a no-op as far as the client is concerned. The failure is completely silent: no error, no panic, no compile complaint. The header simply never appears, which is why this surfaces as "the header shows up in the unit test but not in production" or "it works on the 404 path but not the 200 path" -- the difference being whether the inner handler wrote anything. ## The second WriteHeader A related attempt is to *change* the status from a wrapper on the way out: `w.WriteHeader(http.StatusInternalServerError)` after `next.ServeHTTP`. The status does not change. `net/http` ignores the second call and logs a message of the form `http: superfluous response.WriteHeader call from ...` to the server's error log (`Server.ErrorLog`, or the standard logger when that field is nil). Seeing that line in a service's logs is a strong hint that some layer is trying to write a response that has already been written. ## The one exception: trailers HTTP does allow header fields *after* the body, as trailers, and Go supports them. They must be arranged in advance: either announce the names in the `Trailer` header before writing anything and fill those keys later, or set keys prefixed with `http.TrailerPrefix` after the body has begun. This is a narrow feature -- chunked HTTP/1.1 or HTTP/2, and clients that bother to read trailers -- and it is not a workaround for wanting to set an ordinary header late. ## What the way-out position is genuinely good for Post-`next` code is the **observation** position, and it is excellent at that: - computing elapsed time from a `start` captured on the way in, - emitting one log line summarising the request, - releasing a resource acquired on the way in (use `defer` so it survives a panic), - reading request fields, which are still perfectly valid. What it cannot do is change what the client receives. If a wrapper truly must decide something about the response after the handler has run, the response must not have been written yet -- which means intercepting the writes rather than letting them go straight to the connection, and that is a substantially bigger design with real costs (buffering the whole body, losing streaming, breaking flushes). ## Capture on the way in The habit that avoids the whole class of bug: any value the outbound side needs, take it before calling `next.ServeHTTP` and keep it in a local variable of the per-request closure. A start time, the original path (a handler may have rewritten the URL), a request-scoped identifier -- all of them are cheap to capture on the way in and unreliable or unavailable on the way out. ## Review checklist When this shape appears in a pull request, three questions settle it. Does any statement after `next.ServeHTTP` mutate `w.Header()` or call `w.WriteHeader`? If so, it is dead code and should move above the call. Does the wrapper depend on a value that the inner handler might have changed? Capture it earlier. And is the cleanup on a plain line where a panic would skip it, rather than in a `defer`?

  • What does the server do when a wrapper calls WriteHeader after the handler already wrote a status?
    Nothing to the response: the status stays as the handler set it. The server logs a `http: superfluous response.WriteHeader call from ...` line via `Server.ErrorLog`, or the standard logger if that field is nil. Seeing that message in production usually means two layers both believe they own the response.
  • Is there any way to send header fields after the body has started?
    Only trailers. Announce the field names in the `Trailer` header before writing the body and fill them in later, or set keys prefixed with `http.TrailerPrefix` once writing has begun. It needs chunked HTTP/1.1 or HTTP/2 and a client that reads trailers, so it is not a general fix for a late header.
  • What should a wrapper capture before calling next.ServeHTTP?
    Anything the outbound side needs: a `time.Now()` start, the original request path, any request-scoped value it wants to log. Keep it in a local variable of the per-request closure. The inner handler may rewrite parts of the request, and the response head is unavailable to change afterwards, so on the way out you only report what you already hold.

The header map is the address you write on an envelope. Once the courier has taken it, editing your notes changes nothing about where it goes.

saying these in an interview costs you the question

  • Thinks w.Header() writes to the connection immediately
  • Expects a second WriteHeader to change the status
  • Believes headers flush only when ServeHTTP returns
  • Sets response headers after next.ServeHTTP returns
  • Assumes a handler that wrote a body sent no status