An HTTP handler that writes its response before finishing the request body has a protocol-sensitive edge case. With HTTP/1, Go’s server normally consumes the unread request body before it begins writing the response. That default keeps ordinary handlers simple, but it conflicts with handlers that intentionally exchange data in both directions at the same time.
http.ResponseController.EnableFullDuplex changes that behavior for the current request. It tells the server that the handler intends to interleave reads from Request.Body with writes to the ResponseWriter.
The HTTP/1 default favors read-then-write handlers
Most HTTP handlers fit a simple sequence: read enough request data, compute a result, then write the response. Go’s HTTP/1 server is built around that common shape.
If a handler starts a response while request bytes remain unread, the server normally consumes the remaining body before response writes proceed. This prevents a handler from treating the request and response as independent concurrent streams unless it opts into full-duplex behavior.
That distinction matters for an endpoint whose response depends incrementally on incoming data. Consider a handler that accepts newline-delimited records and emits one acknowledgement for each accepted record. Waiting for the entire upload to finish changes the interaction from bidirectional streaming into a buffered exchange.
EnableFullDuplex makes the intent explicit
A response controller exposes protocol operations without requiring the handler to assert optional interfaces directly:
func streamAcks(w http.ResponseWriter, r *http.Request) {
controller := http.NewResponseController(w)
if err := controller.EnableFullDuplex(); err != nil {
if errors.Is(err, http.ErrNotSupported) {
http.Error(w, "full-duplex HTTP is unavailable", http.StatusHTTPVersionNotSupported)
return
}
http.Error(w, "response control failed", http.StatusInternalServerError)
return
}
scanner := bufio.NewScanner(r.Body)
for scanner.Scan() {
if _, err := fmt.Fprintln(w, "accepted"); err != nil {
return
}
if err := controller.Flush(); err != nil {
return
}
}
if err := scanner.Err(); err != nil {
return
}
}The call belongs before the handler starts the interleaved exchange. Once enabled, HTTP/1 request reads can continue while the handler writes the response.
EnableFullDuplex does not create goroutines, copy request data, or define application framing. The handler still owns those choices. The method changes the server’s request/response coordination so that the intended access pattern is permitted.
HTTP/2 has different server behavior
For HTTP/2 requests, Go’s server already permits concurrent request reads and response writes. EnableFullDuplex is primarily significant for HTTP/1 behavior.
That does not make arbitrary bidirectional exchanges portable across every client. Application code still has to account for the peer, intermediaries, framing, cancellation, and buffering. A reverse proxy can buffer traffic even when the origin handler flushes promptly, and a client library can impose its own request or response flow constraints.
Full duplex is therefore a server capability, not a guarantee about the entire network path.
Flushing is a separate operation
Allowing reads and writes to overlap does not imply that every response write reaches the client immediately. HTTP output can remain buffered.
ResponseController.Flush requests that buffered response data be sent to the client. In a streaming exchange, the two controller operations often appear together for different reasons: EnableFullDuplex permits the access pattern, while Flush controls response buffering.
Keeping those responsibilities separate avoids a common conceptual mistake. A successful flush does not opt an HTTP/1 handler into continued body reads, and enabling full duplex does not force each write onto the network.
Middleware wrappers must remain transparent
http.NewResponseController can operate on the original ResponseWriter or on a wrapper that exposes an Unwrap() http.ResponseWriter method. The controller follows compatible wrappers until it finds the requested capability.
A middleware wrapper that hides the underlying writer can make EnableFullDuplex return an error matching http.ErrNotSupported, even when the server beneath it supports the operation.
A transparent wrapper can preserve controller access:
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
}This pattern matters beyond full duplex. The same controller also exposes flushing, connection hijacking, and per-response read and write deadlines. Middleware that unwraps cleanly composes with those capabilities instead of silently removing them.
Error handling belongs before streaming starts
Controller operations can fail when a writer does not support the requested capability. errors.Is(err, http.ErrNotSupported) is the appropriate test for unsupported response control.
The useful time to discover that limitation is before response bytes are committed. After headers or body data have been written, replacing the exchange with a conventional HTTP error response may no longer be possible.
For an endpoint that requires full duplex as part of its contract, capability failure should stop the exchange before streaming begins. If full duplex is merely an optimization, the handler needs a separate non-streaming path whose semantics remain valid without it.
Full duplex does not remove resource limits
A handler that remains active while both sides transfer data can occupy a connection for a long period. Enabling the access pattern does not add size limits, deadlines, cancellation policy, or backpressure rules.
Request-body limits still need an explicit bound when input is untrusted. Server and per-response deadlines still need values that fit the endpoint. Writes must still check errors, and request cancellation remains relevant when the peer disconnects or abandons the operation.
The mechanism is narrow by design: it changes when Go’s HTTP server permits request reads relative to response writes. Treating it as that specific protocol control keeps the surrounding resource policy visible.
For endpoints that truly need an interleaved HTTP exchange, EnableFullDuplex makes the handler’s contract with the server explicit. The remaining constraints live outside that call: framing, buffering, limits, client behavior, and intermediaries still determine whether the exchange behaves as intended end to end.