A server-level read timeout applies one policy across connections, but a particular handler can have a narrower request-body budget. Go’s http.ResponseController exposes SetReadDeadline for that case. The deadline covers reading the request, including its body, and gives handler code a direct boundary for input that arrives too slowly.

This control is different from limiting body size. A byte limit constrains how much data a handler accepts; a read deadline constrains how long reads may continue. Endpoints that accept streamed or uploaded data often need both dimensions considered separately.

The deadline belongs to request reads

SetReadDeadline accepts an absolute time.Time. Reads from Request.Body after the deadline has passed return an error. Passing the zero value removes the deadline when the underlying response writer supports that operation.

A handler can establish a five-second read boundary like this:

func ingest(w http.ResponseWriter, r *http.Request) {
    controller := http.NewResponseController(w)

    if err := controller.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
        http.Error(w, "request timing control unavailable", http.StatusInternalServerError)
        return
    }

    data, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "request body could not be read", http.StatusRequestTimeout)
        return
    }

    fmt.Fprintf(w, "received %d bytes", len(data))
}

The deadline is not an inactivity timer. It does not automatically move forward each time bytes arrive. The value passed to SetReadDeadline is a fixed point in time, so a request that continuously makes progress can still reach it.

That distinction matters for large bodies. A fixed five-second boundary may be suitable for a small payload but inappropriate for a large upload over a constrained connection. The handler’s expected body size and traffic pattern need to match the selected time budget.

An expired deadline cannot be extended

The API has a specific boundary after expiration: setting a later read deadline after the existing deadline has already been exceeded does not extend it. Code that intends to adjust a deadline must do so before expiration.

This rules out a simple recovery pattern in which a handler waits for a read to time out and then grants more time. Once the deadline has been exceeded, the request should be treated as having crossed that read boundary.

For protocols implemented inside an HTTP request body, deadline updates can still be useful before expiration. A handler might set an initial boundary for a header-like portion of the body, parse it, then replace the deadline with a later absolute time for the remaining data. That design depends on setting the new value while the prior deadline is still active.

ResponseController preserves optional capabilities through wrappers

http.ResponseController is also useful at middleware boundaries. The controller starts from the http.ResponseWriter supplied to the handler and looks for supported control methods. A wrapper can expose its underlying writer with an Unwrap() http.ResponseWriter method, allowing the controller to continue through compatible layers.

type statusWriter struct {
    http.ResponseWriter
    status int
}

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

func (w *statusWriter) Unwrap() http.ResponseWriter {
    return w.ResponseWriter
}

Without compatible unwrapping, a middleware wrapper can hide optional response-control capabilities even though the original server writer supports them. That can turn a working deadline call into an unsupported operation after middleware is introduced.

The controller reports unsupported operations with an error matching http.ErrNotSupported. Handler code should check the returned error instead of assuming every ResponseWriter implementation provides deadline control.

err := http.NewResponseController(w).SetReadDeadline(deadline)
if errors.Is(err, http.ErrNotSupported) {
    // apply an endpoint policy for writers without deadline support
}

A test double, alternate server implementation, or specialized wrapper can differ from the standard server writer. Treating support as a capability keeps the handler’s behavior explicit.

Read deadlines and body-size limits solve different problems

http.MaxBytesReader and SetReadDeadline are complementary rather than interchangeable. MaxBytesReader stops a request body after a configured byte count. SetReadDeadline places a time boundary on reads.

For an endpoint that accepts at most 1 MiB, combining both controls can express two independent constraints:

func receive(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20)

    controller := http.NewResponseController(w)
    if err := controller.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
        http.Error(w, "request timing control unavailable", http.StatusInternalServerError)
        return
    }

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "request rejected", http.StatusBadRequest)
        return
    }

    fmt.Fprintf(w, "accepted %d bytes", len(body))
}

The byte cap protects the handler from accepting an unexpectedly large body. The deadline bounds the period during which request reads are allowed. Neither control substitutes for the other.

A server’s broader timeout configuration still matters. Per-request control is most useful when one handler needs a boundary that differs from the server-wide policy, not as a reason to omit connection-level timeout settings.

Keep the boundary tied to the endpoint

A read deadline is easiest to reason about when it reflects a concrete property of the endpoint: expected payload size, request shape, and acceptable read duration. Applying an aggressive value uniformly can reject legitimate large or slow transfers, while a very loose value may provide little additional constraint.

http.ResponseController.SetReadDeadline gives a handler a narrow control surface without taking over the connection. Its useful scope is equally narrow: establish the read boundary before it expires, preserve controller access through middleware wrappers, and pair time limits with separate size limits when both constraints matter.