skip to content

Your timing middleware wraps http.ResponseWriter to record the status, and a streaming endpoint under it stops flushing. Why?

level: seniorimportance: should knowfreq 40%

answer

  1. the wrapper is a different type now
  2. embedding promotes only what the interface declares
  3. the handler asks with a type assertion
  4. a failed comma-ok assertion says nothing
  5. Unwrap, then http.NewResponseController

basics

~10 s

The handler probes its writer with a type assertion to http.Flusher, and the wrapper does not satisfy it, so flushing is silently skipped. Give the wrapper an Unwrap method and flush through http.NewResponseController.

solid answer

~40 s

Streaming handlers write, then do `if f, ok := w.(http.Flusher); ok { f.Flush() }`. Embedding `http.ResponseWriter` in your wrapper promotes only that interface's three methods — `Header`, `Write`, `WriteHeader` — so the wrapper does not satisfy `http.Flusher`, the assertion returns false, and the handler silently skips flushing. Nothing errors; the response just sits in the connection's buffer until it fills or the handler returns, so a server-sent-events route appears to hang. The same silent loss hits `http.Hijacker` for connection upgrades and `io.ReaderFrom` for the copy fast path. The fix is two-sided: give the wrapper `Unwrap() http.ResponseWriter` returning the embedded writer, and have handlers use `http.NewResponseController(w)`, whose `Flush`, `Hijack` and deadline methods follow that unwrap chain and return an error when the capability really is unavailable, instead of failing quietly.

code

go · 12 lines
go
type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(code int) {
	w.status = code
	w.ResponseWriter.WriteHeader(code)
}

// Lets http.ResponseController reach the real writer's Flush and Hijack.
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

go deeper

for a junior

Know that embedding http.ResponseWriter in a struct gives you only its three methods, and that streaming handlers need Flush, which is not one of them.

for a middle

Explain how optional capabilities are discovered by type assertion, why the comma-ok form loses them silently, and what Unwrap plus http.NewResponseController does about it.

for a senior

Recognise the production symptom — a stream that stalls after a middleware change, with no error anywhere — and be able to state the review rule and the test that would have caught it.

for a principal

Decide whether a shared middleware is safe to apply to every route by default, given that its cost lands on the streaming and upgrade paths that are least covered by tests.

## The wrapper a timing middleware needs `ServeHTTP` returns nothing, so middleware that wants to record a status alongside a duration has to interpose on the writer: ```go type statusWriter struct { http.ResponseWriter status int } func (w *statusWriter) WriteHeader(code int) { w.status = code w.ResponseWriter.WriteHeader(code) } ``` Embedding forwards `Header`, `Write` and `WriteHeader` for free and the override captures the code. It looks complete, and for ordinary JSON endpoints it is. ## What embedding an interface actually promotes Embedding a value of interface type promotes exactly the methods **declared in that interface**. `http.ResponseWriter` declares three: `Header`, `Write`, `WriteHeader`. Whatever concrete type sits behind it may also have `Flush`, `Hijack`, `ReadFrom`, `SetReadDeadline` and more — but those are not in the interface's method set, so they are not promoted, and the wrapper does not satisfy the interfaces that name them. That matters because `net/http`'s optional capabilities are discovered by type assertion at the call site: ```go if f, ok := w.(http.Flusher); ok { f.Flush() } ``` Against the real writer the assertion succeeds. Against your wrapper it fails, and the well-behaved `ok` form means the handler does nothing rather than complaining. This is the whole failure: **the capability is lost silently.** ## What that looks like in production A server-sent-events or `text/event-stream` endpoint writes an event and flushes after each one. Under the wrapper the flush is skipped, so bytes accumulate in the connection's buffered writer and are only sent when it fills or when the handler finally returns. From the client's side the stream is dead; from the server's side the handler is running normally and your latency metric is happily counting a request that will take minutes. A WebSocket-style upgrade fails differently and more loudly, since `w.(http.Hijacker)` failing usually ends in a 500. Losing `io.ReaderFrom` costs performance rather than correctness: `io.Copy` falls back to a buffered loop instead of the writer's fast path. ## The modern fix: Unwrap plus http.ResponseController Go's answer is a convention plus a helper. The convention: a wrapper declares ```go func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter } ``` The helper: handlers stop asserting and call ```go rc := http.NewResponseController(w) if err := rc.Flush(); err != nil { // genuinely unsupported, and now you know } ``` `http.ResponseController` walks the chain of `Unwrap` methods to find a writer that supports the operation, so any number of nested middlewares can sit in between as long as each one unwraps. Its methods return an error when the capability is unavailable, converting a silent no-op into something you can log or fail on. It also exposes per-request deadline control, which is the supported way to give one streaming handler a different write deadline from the server-wide one. If you cannot change the handlers — a third-party one, or a library you do not own — the older approach still works: re-declare the methods you must preserve on the wrapper and forward them after a type assertion on the embedded writer. It is verbose, easy to forget one, and exactly the pain `Unwrap` removes. ## The status your timing code records While you are in this code, decide two edge cases explicitly. A handler that only calls `Write` never calls `WriteHeader`, so `net/http` implies 200 and your wrapper's field is still zero — initialise it to `http.StatusOK`, or normalise zero to 200 at record time. And a handler that panics before writing anything leaves the field at zero with the response never completed; recording that as 200 is a lie, so give it its own value in the metric. ## The review rule Any wrapper around `http.ResponseWriter` needs `Unwrap`, and any handler that flushes or hijacks should go through `http.NewResponseController`. It costs one line on each side, and it is the difference between a middleware you can safely add to every route and one that breaks the two routes nobody tested.

  • Which capabilities besides flushing does such a wrapper hide?
    `http.Hijacker`, which connection upgrades need and whose loss usually surfaces as a 500; `io.ReaderFrom`, whose loss makes `io.Copy` fall back to a buffered loop and costs throughput rather than correctness; and the per-request read and write deadline control that `http.ResponseController` otherwise exposes. All are discovered by type assertion, so all fail quietly.
  • What status does the wrapper record for a handler that only calls Write?
    Zero, unless you handle it. `net/http` implies 200 when a handler writes without calling `WriteHeader`, but your field never sees that. Initialise it to `http.StatusOK`, or map zero to 200 when recording. Keep a separate value for a handler that panicked before writing, since calling that a 200 hides the failure.
  • How would you catch this class of bug before it reaches production?
    A test that runs the real middleware chain around a handler asserting on `http.Flusher` and `http.Hijacker` — or calling `http.NewResponseController(w).Flush()` and checking the error. `httptest` makes it cheap, and it is the only kind of test that notices a capability silently disappearing when someone adds a new wrapper.

saying these in an interview costs you the question

  • Believing embedding an interface promotes the concrete type's extra methods
  • Assuming a failed comma-ok type assertion produces an error somewhere
  • Adding a ResponseWriter wrapper without an Unwrap method
  • Recording status zero as 200, including for panicking handlers
  • Blaming the client or the proxy when a streaming route stops delivering