Skip to content

Stream a response (and survive the server's timeouts)

transport servers apply timeouts that suit ordinary request/response traffic and are wrong for a long-lived one. A streamed response hits WriteTimeout and is cut mid-flight; a large upload hits ReadTimeout and is cut mid-body.

Both are escapable per request. Neither is escapable by accident, which is why this page exists.

The policy

Register and NewServer apply these defaults:

setting default what it bounds
ReadTimeout 5s reading the whole request, headers and body
WriteTimeout 10s writing the whole response
IdleTimeout 120s waiting between requests on a keep-alive connection
DefaultMaxRequestBodyBytes 1 MiB the size of each request body

WriteTimeout bounds the entire response, not an individual write. For a stream that makes it a cap on total stream duration: a reply that takes 13s on a server with the 10s default delivers about 10s of content and then stops.

Stream a response

Set the content type, opt this request out of the write deadline, and check the error from every write:

import (
    "net/http"
    "time"

    transporthttp "gitlab.com/phpboyscout/go/transport/http"
)

func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", transporthttp.EventStreamContentType)

    // Opt THIS request out of the write deadline. Without it the stream is cut
    // at WriteTimeout.
    if err := http.NewResponseController(w).SetWriteDeadline(time.Time{}); err != nil {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)

        return
    }

    for token := range s.tokens(r.Context()) {
        // Always check the error. This is the signal that the stream has been
        // cut — see "How you find out" below.
        if err := transporthttp.WriteEvent(w, "token", []byte(token)); err != nil {
            return
        }
    }

    _ = transporthttp.WriteEvent(w, "done", nil)
}

WriteEvent writes one Server-Sent Event frame and flushes it. It handles the framing details that are easy to get wrong: multi-line payloads become consecutive data: lines, so content containing a newline cannot forge additional fields, and an event name containing a newline is rejected outright.

It deliberately does not touch the write deadline. Clearing it inside the helper would remove the server's only bound on a client that opens a stream and stops reading — for every caller, without saying so. The opt-out stays yours.

How you find out when a stream is cut

Check the error returned by every write. That is the whole answer, and the detail below is why there is no better one.

An expired write deadline does not notify the handler on its own. Nothing at all happens until the handler next attempts a write, at which point that write fails and the request context is cancelled — together, in the same instant.

Two consequences:

  • r.Context().Done() is not an earlier signal than the write error. It is the same event. Selecting on it is useful for noticing a client that hung up between writes, not for catching the deadline sooner.
  • A handler idle across the deadline learns nothing until it writes. If your stream can pause — an LLM thinking between tokens, a job reporting progress every 30s — the cut is discovered at the next write, however long that takes.

If a stream can pause for longer than WriteTimeout, clear the deadline. Do not rely on noticing.

Do not disable the timeout server-wide

WithWriteTimeout(0) disables the write deadline for every route on the server, not just the streaming one. That is a trap, not a shortcut.

WriteTimeout is what bounds a client that opens a response and stops reading. With it cleared, such a client pins a goroutine, a connection and its buffers for as long as it likes, and nothing else rescues you:

  • IdleTimeout applies only between requests. A connection mid-response is not idle, so it never engages.
  • ReadTimeout has already been satisfied — the request was read.

Per-request SetWriteDeadline scopes the exception to the one handler that needs it. Prefer it always.

Accept a large or slow upload

ReadTimeout (5s) covers reading the request body, so a large or slow upload is cut mid-body. The escape mirrors the write side:

func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
    if err := http.NewResponseController(w).SetReadDeadline(time.Time{}); err != nil {
        http.Error(w, "cannot extend read deadline", http.StatusInternalServerError)

        return
    }

    // r.Body can now be read for as long as it takes.
}

The same warning applies: WithReadTimeout(0) disables it for every route. It is also the module's only bound on a slow client trickling headers, because ReadHeaderTimeout is not set separately — so clearing it server-wide costs more than it appears to.

Request bodies remain capped at DefaultMaxRequestBodyBytes (1 MiB) regardless; raise that with WithMaxRequestBodyBytes.

Middleware must stay transparent

Both escapes reach the real connection through http.NewResponseController, which walks the middleware chain via Unwrap(). Any middleware that wraps the ResponseWriter must implement Unwrap() http.ResponseWriter, and must implement FlushError() error rather than only Flush().

That second requirement is not a nicety. ResponseController.Flush matches http.Flusher before it tries Unwrap, so a wrapper offering only Flush ends the walk and returns nil — discarding the real error and turning a cut stream back into a silent one. The middleware this module ships satisfies both, and a test enforces it.

Testing a handler that streams

Test against a real server, not httptest.

httptest.NewServer sets none of these timeouts and no body cap, so a handler tested against it passes whether or not the policy would cut it in production. That is not a hypothetical: it is how the truncation this page documents reached a consumer's users with a green test suite.

Build the server through Register with the settings you want to assert against, and make the test stream for longer than the deadline it is testing.

Where the timeout defaults are defined

The numbers on this page are the package defaults, not settings read from anywhere. The full table — with the options that change each one and what a zero value means — is in the HTTP server reference, and the reasons per-route variants do not exist are in What transport does not do.