http.Server.WriteTimeout is not a per-write timeout. The server installs it once, as an absolute deadline over the entire response, when the request is read. Any SSE / streaming handler that lives longer than that window is killed by the transport at the deadline, no matter how often it calls Flush(). Flush sends buffered bytes; it does not move the deadline.
I built and ran a full reproduction in ~/sse-demo (Go 1.26), verified the pre-fix failure, the fix, the chi middleware path, and the falsification/restore cycle. Here is the solution document (also saved at ~/sse-demo/SOLUTION.md).
http.Server.WriteTimeouthttp.Server.WriteTimeout is not a per-write timeout. The server installs it once, as an absolute deadline over the entire response, when the request is read. Any SSE / streaming handler that lives longer than that window is killed by the transport at the deadline, no matter how often it calls Flush(). Flush sends buffered bytes; it does not move the deadline.
The fix is to take ownership of the connection's write deadline with http.ResponseController: clear it after the response header is written, then set a bounded, per-frame deadline before each subsequent write. This lets a long-lived stream survive while still dropping a peer that stops reading.
In net/http, when a connection is being read for a request, the server sets a single write deadline for the whole response (server.go):
// readRequest
if d := c.server.WriteTimeout; d > 0 {
defer func() {
c.rwc.SetWriteDeadline(time.Now().Add(d))
}()
}
SetWriteDeadline takes an absolute time. It is set once, at request read, and is never advanced by Write, Flush, or http.Flusher. So the transport enforces:
response deadline = request_read_time + WriteTimeout
A streaming handler that opens at t=0 and must emit at t = 1s, 2s, 3s… fails its next write once t > WriteTimeout, independent of frame cadence. The client observes the TCP connection closing — an EOF mid-stream.
httptest-based tests with an injected ticker do not see this, because a recorder has no real transport deadline. Only a real http.Server with a real WriteTimeout and a real TCP client reproduce it.
WriteTimeout also protects the server from a stuck peer. If the handler simply clears the deadline, a client that stops reading can block a write forever and pin a goroutine. The correct shape is:
now + N) immediately before each frame.http.NewResponseController(w) reaches the actual *http.response even through middleware wrappers, as long as the wrapper implements Unwrap() http.ResponseWriter. chi's WrapResponseWriter (flushWriter, httpFancyWriter, etc.) embeds basicWriter, which implements Unwrap(), so the controller walks through it.
func StreamHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
// 1. Take ownership: clear the server's whole-response WriteTimeout.
// A zero time clears the deadline. Returns ErrNotSupported on writers
// that don't expose a deadline (e.g. HTTP/2), which is fine.
if err := rc.SetWriteDeadline(time.Time{}); err != nil && err != http.ErrNotSupported {
return
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
ticker := time.NewTicker(frameInterval)
defer ticker.Stop()
for i := 0; ; {
select {
case <-r.Context().Done():
return
case <-ticker.C:
i++
// 2. Re-arm a bounded deadline BEFORE every write, so a peer
// that stops reading still errors out instead of blocking.
if err := rc.SetWriteDeadline(time.Now().Add(frameWriteTimeout)); err != nil && err != http.ErrNotSupported {
return
}
if _, err := fmt.Fprintf(w, "data: frame %d\n\n", i); err != nil {
return
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}
}
Design notes:
frameWriteTimeout must be larger than the frame interval (e.g. 5s deadline for 1s frames) so normal frames never trip it.Fprintf and before the subsequent Flush; both write to the socket and should share the same bounded window.err != http.ErrNotSupported guard keeps the handler working on transports that do not implement SetWriteDeadline.http.ResponseController) and a chi version whose wrap writer implements Unwrap() (chi v5).The server is unchanged and still uses WriteTimeout for short request/response handlers:
srv := &http.Server{
Handler: routes,
WriteTimeout: 10 * time.Second,
}
The regression test must use a real http.Server with a short WriteTimeout and a real TCP client that reads past 3× the deadline.
func TestSSEOutlivesWriteTimeout(t *testing.T) {
ln, _ := net.Listen("tcp", "<ip-address>:0")
srv := &http.Server{
Handler: http.HandlerFunc(StreamHandler),
WriteTimeout: 300 * time.Millisecond, // short, whole-response
}
go srv.Serve(ln)
defer srv.Close()
conn, _ := net.Dial("tcp", ln.Addr().String())
defer conn.Close()
fmt.Fprintf(conn,
"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n",
ln.Addr().String())
// Read well past 3x the server WriteTimeout.
conn.SetReadDeadline(time.Now().Add(3*300*time.Millisecond + 300*time.Millisecond))
frames := 0
sc := bufio.NewScanner(conn)
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "data:") {
frames++
}
}
readErr := sc.Err()
// Live stream => we hit OUR read deadline (non-nil i/o timeout).
// Killed stream => EOF from Scanner, which reports a nil error.
if readErr == nil {
t.Fatalf("stream ended early (EOF) after %d frames: WriteTimeout killed it", frames)
}
if frames < 8 {
t.Fatalf("only %d frames before read deadline; want >= 8", frames)
}
}
Pre-fix handler (flush only, no deadline control):
frames=2 readErr=<nil>
stream ended early (EOF) after 2 frames: WriteTimeout killed it
--- FAIL: TestSSEOutlivesWriteTimeout (0.30s)
Post-fix handler:
frames=11 readErr=read tcp <ip-address>:38638-><ip-address>:45349: i/o timeout
--- PASS: TestSSEOutlivesWriteTimeout (1.20s)
Through chi middleware (middleware.NewWrapResponseWriter):
chi: frames=11 readErr=read tcp ...: i/o timeout
--- PASS: TestSSEThroughChiMiddleware (1.20s)
Full suite under the race detector:
$ go test -race ./...
ok ssedemo 3.415s
# With the fix committed:
git checkout HEAD~1 -- handler.go # pre-fix handler
go test -run TestSSEOutlivesWriteTimeout # FAIL: EOF after 2 frames
git checkout HEAD -- handler.go # restore fixed file
git status --porcelain # empty => byte-identical restore
go test ./... # PASS
This was executed end-to-end: the pre-fix run failed at exactly the 300 ms deadline with frames=2, and git status --porcelain was empty after restore.
WriteTimeout on the streaming server is short enough to reproduce and frames are more frequent than the deadline.SetWriteDeadline(time.Time{}) after the header flush.SetWriteDeadline(time.Now().Add(bounded)) before every write (and its flush).SetWriteDeadline other than ErrNotSupported terminate the stream.http.Server + real TCP client reading past 3× the deadline; it asserts the connection is still open (non-EOF).Artifacts: ~/sse-demo/handler.go (fix), sse_test.go (real-server regression test), chi_test.go (middleware path), SOLUTION.md. All verified: pre-fix fails with EOF at the deadline, post-fix survives past 3×, chi path works via Unwrap(), race-clean, and restore is byte-identical.
# Evidence - Problem class: go-sse-server-writetimeout - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-19T17:52:00.206Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A net/http server WriteTimeout covers the WHOLE response lifetime, so any SSE/streaming route whose first frame lands inside the window dies at the deadline no matter how often the handler flushes. Fix shape: http.NewResponseController(w).SetWriteDeadline(time.Time{}) after the header flush (zero Time clears), then a bounded per-frame SetWriteDeadline(now+N) before every subsequent write so a stuck peer still gets dropped. Works through chi middleware because chi wrap writers implement Unwrap(). Regression test must use a real http.Server with a SHORT WriteTimeout and a real TCP client reading past 3x the deadline \u2014 injected-interval httptest tests cannot see the transport deadline. Falsification = check out the pre-fix handler file, expect EOF/connection-ended, restore byte-identical.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-sse-server-writetimeout", "provider": "openrouter", "solved_at": "2026-09-19T17:52:00.206Z", "version": ""}I built and ran a full reproduction in ~/sse-demo (Go 1.26), verified the pre-fix failure, the fix, the chi middleware path, and the falsification/restore cycle. Here is the solution document (also saved at ~/sse-demo/SOLUTION.md).
http.Server.WriteTimeouthttp.Server.WriteTimeout is not a per-write timeout. The server installs it once, as an absolute deadline over the entire response, when the request is read. Any SSE / streaming handler that lives longer than that window is killed by the transport at the deadline, no matter how often it calls Flush(). Flush sends buffered bytes; it does not move the deadline.
The fix is to take ownership of the connection's write deadline with http.ResponseController: clear it after the response header is written, then set a bounded, per-frame deadline before each subsequent write. This lets a long-lived stream survive while still dropping a peer that stops reading.
In net/http, when a connection is being read for a request, the server sets a single write deadline for the whole response (server.go):
// readRequest
if d := c.server.WriteTimeout; d > 0 {
defer func() {
c.rwc.SetWriteDeadline(time.Now().Add(d))
}()
}
SetWriteDeadline takes an absolute time. It is set once, at request read, and is never advanced by Write, Flush, or http.Flusher. So the transport enforces:
response deadline = request_read_time + WriteTimeout
A streaming handler that opens at t=0 and must emit at t = 1s, 2s, 3s… fails its next write once t > WriteTimeout, independent of frame cadence. The client observes the TCP connection closing — an EOF mid-stream.
httptest-based tests with an injected ticker do not see this, because a recorder has no real transport deadline. Only a real http.Server with a real WriteTimeout and a real TCP client reproduce it.
WriteTimeout also protects the server from a stuck peer. If the handler simply clears the deadline, a client that stops reading can block a write forever and pin a goroutine. The correct shape is:
now + N) immediately before each frame.http.NewResponseController(w) reaches the actual *http.response even through middleware wrappers, as long as the wrapper implements Unwrap() http.ResponseWriter. chi's WrapResponseWriter (flushWriter, httpFancyWriter, etc.) embeds basicWriter, which implements Unwrap(), so the controller walks through it.
func StreamHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
rc := http.NewResponseController(w)
// 1. Take ownership: clear the server's whole-response WriteTimeout.
// A zero time clears the deadline. Returns ErrNotSupported on writers
// that don't expose a deadline (e.g. HTTP/2), which is fine.
if err := rc.SetWriteDeadline(time.Time{}); err != nil && err != http.ErrNotSupported {
return
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
ticker := time.NewTicker(frameInterval)
defer ticker.Stop()
for i := 0; ; {
select {
case <-r.Context().Done():
return
case <-ticker.C:
i++
// 2. Re-arm a bounded deadline BEFORE every write, so a peer
// that stops reading still errors out instead of blocking.
if err := rc.SetWriteDeadline(time.Now().Add(frameWriteTimeout)); err != nil && err != http.ErrNotSupported {
return
}
if _, err := fmt.Fprintf(w, "data: frame %d\n\n", i); err != nil {
return
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
}
}
}
Design notes:
frameWriteTimeout must be larger than the frame interval (e.g. 5s deadline for 1s frames) so normal frames never trip it.Fprintf and before the subsequent Flush; both write to the socket and should share the same bounded window.err != http.ErrNotSupported guard keeps the handler working on transports that do not implement SetWriteDeadline.http.ResponseController) and a chi version whose wrap writer implements Unwrap() (chi v5).The server is unchanged and still uses WriteTimeout for short request/response handlers:
srv := &http.Server{
Handler: routes,
WriteTimeout: 10 * time.Second,
}
The regression test must use a real http.Server with a short WriteTimeout and a real TCP client that reads past 3× the deadline.
func TestSSEOutlivesWriteTimeout(t *testing.T) {
ln, _ := net.Listen("tcp", "<ip-address>:0")
srv := &http.Server{
Handler: http.HandlerFunc(StreamHandler),
WriteTimeout: 300 * time.Millisecond, // short, whole-response
}
go srv.Serve(ln)
defer srv.Close()
conn, _ := net.Dial("tcp", ln.Addr().String())
defer conn.Close()
fmt.Fprintf(conn,
"GET /events HTTP/1.1\r\nHost: %s\r\nAccept: text/event-stream\r\n\r\n",
ln.Addr().String())
// Read well past 3x the server WriteTimeout.
conn.SetReadDeadline(time.Now().Add(3*300*time.Millisecond + 300*time.Millisecond))
frames := 0
sc := bufio.NewScanner(conn)
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "data:") {
frames++
}
}
readErr := sc.Err()
// Live stream => we hit OUR read deadline (non-nil i/o timeout).
// Killed stream => EOF from Scanner, which reports a nil error.
if readErr == nil {
t.Fatalf("stream ended early (EOF) after %d frames: WriteTimeout killed it", frames)
}
if frames < 8 {
t.Fatalf("only %d frames before read deadline; want >= 8", frames)
}
}
Pre-fix handler (flush only, no deadline control):
frames=2 readErr=<nil>
stream ended early (EOF) after 2 frames: WriteTimeout killed it
--- FAIL: TestSSEOutlivesWriteTimeout (0.30s)
Post-fix handler:
frames=11 readErr=read tcp <ip-address>:38638-><ip-address>:45349: i/o timeout
--- PASS: TestSSEOutlivesWriteTimeout (1.20s)
Through chi middleware (middleware.NewWrapResponseWriter):
chi: frames=11 readErr=read tcp ...: i/o timeout
--- PASS: TestSSEThroughChiMiddleware (1.20s)
Full suite under the race detector:
$ go test -race ./...
ok ssedemo 3.415s
# With the fix committed:
git checkout HEAD~1 -- handler.go # pre-fix handler
go test -run TestSSEOutlivesWriteTimeout # FAIL: EOF after 2 frames
git checkout HEAD -- handler.go # restore fixed file
git status --porcelain # empty => byte-identical restore
go test ./... # PASS
This was executed end-to-end: the pre-fix run failed at exactly the 300 ms deadline with frames=2, and git status --porcelain was empty after restore.
WriteTimeout on the streaming server is short enough to reproduce and frames are more frequent than the deadline.SetWriteDeadline(time.Time{}) after the header flush.SetWriteDeadline(time.Now().Add(bounded)) before every write (and its flush).SetWriteDeadline other than ErrNotSupported terminate the stream.http.Server + real TCP client reading past 3× the deadline; it asserts the connection is still open (non-EOF).Artifacts: ~/sse-demo/handler.go (fix), sse_test.go (real-server regression test), chi_test.go (middleware path), SOLUTION.md. All verified: pre-fix fails with EOF at the deadline, post-fix survives past 3×, chi path works via Unwrap(), race-clean, and restore is byte-identical.
# Evidence - Problem class: go-sse-server-writetimeout - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-19T17:52:00.206Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A net/http server WriteTimeout covers the WHOLE response lifetime, so any SSE/streaming route whose first frame lands inside the window dies at the deadline no matter how often the handler flushes. Fix shape: http.NewResponseController(w).SetWriteDeadline(time.Time{}) after the header flush (zero Time clears), then a bounded per-frame SetWriteDeadline(now+N) before every subsequent write so a stuck peer still gets dropped. Works through chi middleware because chi wrap writers implement Unwrap(). Regression test must use a real http.Server with a SHORT WriteTimeout and a real TCP client reading past 3x the deadline \u2014 injected-interval httptest tests cannot see the transport deadline. Falsification = check out the pre-fix handler file, expect EOF/connection-ended, restore byte-identical.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-sse-server-writetimeout", "provider": "openrouter", "solved_at": "2026-09-19T17:52:00.206Z", "version": ""}