Go's http.ResponseController: Resetting Streaming Deadlines
A normal HTTP response has a fairly obvious lifetime: the handler produces some bytes, writes them, and returns. A streaming response is awkward because it might remain open for hours while doing almost nothing. Server-sent events, long-running exports and incremental status feeds all fit this shape.
Setting http.Server.WriteTimeout to ten seconds does not mean "allow each write ten seconds". It installs an absolute write deadline for the response. A perfectly healthy stream can therefore work for ten seconds and then abruptly lose its connection when the next flush reaches the network.
Leaving write timeouts disabled is not much better. A client which stops reading can eventually fill the TCP send buffer and leave a handler blocked in Write indefinitely. Enough of those clients and the server acquires a rather unhelpful collection of stuck goroutines.
http.ResponseController provides the missing per-response control. The useful pattern is to install a short deadline immediately before writing each chunk, flush the chunk, then clear the deadline while waiting for the next event.
WriteTimeout is a lifetime, not an idle timeout
The documentation describes Server.WriteTimeout as the maximum duration before timing out writes, reset when a new request's headers are read. For an ordinary response that is often good enough. For a stream it acts more like a maximum response age.
Suppose an SSE handler emits one event every second and the server has a five-second WriteTimeout. The first few events arrive normally. Once the original deadline passes, a later write or flush fails even though every earlier operation completed immediately.
This distinction is easy to miss because socket deadlines are absolute times. They are not durations attached independently to each call. The server calculates a time such as 14:03:05, installs it on the connection or stream, and subsequent operations inherit it.
Go 1.20 introduced ResponseController, including per-request read and write deadlines. Calling SetWriteDeadline replaces the server's existing deadline for that response. Passing a zero time.Time removes it.
A rolling deadline around every flushed chunk
Here is a small SSE handler. The event source is deliberately uninteresting so the deadline handling remains visible.
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"time"
)
const writeAllowance = 5 * time.Second
func writeChunk(
rc *http.ResponseController,
w http.ResponseWriter,
chunk []byte,
) error {
if err := rc.SetWriteDeadline(time.Now().Add(writeAllowance)); err != nil {
return fmt.Errorf("set write deadline: %w", err)
}
if _, err := w.Write(chunk); err != nil {
return fmt.Errorf("write chunk: %w", err)
}
if err := rc.Flush(); err != nil {
return fmt.Errorf("flush chunk: %w", err)
}
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
return fmt.Errorf("clear write deadline: %w", err)
}
return nil
}
func events(w http.ResponseWriter, r *http.Request) {
rc := http.NewResponseController(w)
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
if err := writeChunk(rc, w, []byte(": connected\n\n")); err != nil {
if errors.Is(err, http.ErrNotSupported) {
http.Error(w, "streaming is not supported", http.StatusInternalServerError)
}
return
}
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
sequence := 0
for {
select {
case <-r.Context().Done():
return
case now := <-ticker.C:
sequence++
payload, err := json.Marshal(struct {
Sequence int `json:"sequence"`
Time time.Time `json:"time"`
}{sequence, now})
if err != nil {
log.Printf("encode event: %v", err)
return
}
chunk := append([]byte("data: "), payload...)
chunk = append(chunk, '\n', '\n')
if err := writeChunk(rc, w, chunk); err != nil {
log.Printf("send event: %v", err)
return
}
}
}
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /events", events)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
The initial comment is an SSE heartbeat. More importantly, it commits and flushes the response while the same write protection is active as for later events. If SetWriteDeadline is unsupported, it fails before Write has committed the status in the usual case, so an error response is still possible.
After a successful flush, the zero deadline matters. Without it, the five-second deadline remains active during the fifteen-second wait for the ticker. By the time the next event arrives, the deadline is already in the past. Trying to revive a response after an expired deadline is not a recovery strategy: the ResponseController documentation explicitly warns that setting a write deadline after it has been exceeded will not extend it.
Why Flush is part of the error path
ResponseWriter.Write need not write to the socket immediately. The HTTP server may accept a small event into its own buffer, so Write can return successfully even when the network is no longer making progress. The documented behaviour is slightly surprising: writes after the deadline may still succeed when the data is buffered.
ResponseController.Flush pushes buffered response data towards the client and, unlike the older http.Flusher interface, returns an error. That makes it the operation which commonly reveals a slow or disconnected peer. Ignoring its result defeats much of the point of adding a deadline.
A flush only proves that the server handed the data further down the stack. It does not prove that an application at the other end processed it. Kernel buffers, TLS records and reverse proxies all sit between those two facts. The deadline is backpressure protection, not delivery confirmation.
Wrappers must expose what they wrap
ResponseController discovers optional operations by examining the supplied ResponseWriter. If middleware replaces it with a logging or compression wrapper, that wrapper should implement Unwrap() http.ResponseWriter. The controller follows that method until it reaches a writer supporting the requested operation.
type statusWriter struct {
http.ResponseWriter
status int
}
func (w *statusWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *statusWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
Without Unwrap, calls such as Flush and SetWriteDeadline can return an error matching http.ErrNotSupported, even though Go's underlying server supports them. Test doubles deserve the same attention: httptest.ResponseRecorder is useful, but it cannot reproduce socket backpressure or deadline expiry. An end-to-end test needs a real server and a client which deliberately stops reading.
What the deadline does not cover
It does not limit how long event generation takes. Use request context cancellation or a separate operation timeout around database calls and other upstream work.
It does not detect an idle but otherwise healthy stream. Heartbeats are an application and intermediary concern, separate from write deadlines.
It does not make concurrent writes safe. Keep ownership of the writer, controller and flush sequence in one goroutine.
It cannot rescue a response after a write has timed out. Return from the handler and let the server tear down the failed response.
The controller itself also belongs to the handler invocation. The standard library source and documentation say it must not be used after ServeHTTP returns. A background goroutine which retains it has the same lifetime bug as one retaining the original ResponseWriter.
The useful mental model is small: each chunk gets a bounded opportunity to reach the transport, and quiet time gets no deadline at all. That preserves a server-wide WriteTimeout for ordinary endpoints without forcing streams to choose between an arbitrary maximum age and clients which can block them forever.