Introduction
When building HTTP middleware in Go, response bodies from upstream services must be properly drained and closed. If a middleware reads part of a response body and then passes a modified response downstream, the remaining body content may never be consumed, causing goroutine leaks in the HTTP transport.
This issue is particularly common in API gateways, reverse proxies, and logging middleware that inspect response bodies.
Symptoms
- Goroutine count increases over time in API gateway or proxy applications
- HTTP transport readLoop goroutines accumulate in pprof output
- Intermittent hangs on subsequent requests to the same upstream host
Common Causes
- Middleware reads part of response.Body but does not drain the rest
- Response body wrapper (e.g., for logging) replaces the original Body without preserving Close
- Error paths in middleware skip the deferred resp.Body.Close() of the downstream handler
Step-by-Step Fix
- 1.Always drain the body before replacing it: Ensure the full body is consumed.
- 2.```go
- 3.func LoggingMiddleware(next http.Handler) http.Handler {
- 4.return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- 5.// Wrap the ResponseWriter to capture the body
- 6.buf := &bytes.Buffer{}
- 7.mw := &responseWriter{ResponseWriter: w, body: buf}
next.ServeHTTP(mw, r)
// Log the response body log.Printf("Response: %d - %s", mw.StatusCode(), buf.String()) }) }
type responseWriter struct { http.ResponseWriter body *bytes.Buffer statusCode int }
func (rw *responseWriter) Write(b []byte) (int, error) { rw.body.Write(b) return rw.ResponseWriter.Write(b) }
func (rw *responseWriter) WriteHeader(code int) { rw.statusCode = code rw.ResponseWriter.WriteHeader(code) } ```
- 1.Drain response body from upstream proxy requests: When proxying, ensure the body is fully consumed.
- 2.```go
- 3.func proxyHandler(w http.ResponseWriter, r *http.Request) {
- 4.resp, err := http.Get("http://upstream-service/api")
- 5.if err != nil {
- 6.http.Error(w, "proxy error", http.StatusBadGateway)
- 7.return
- 8.}
- 9.defer resp.Body.Close()
// Copy the body to response writer - this drains it fully w.Header().Set("Content-Type", resp.Header.Get("Content-Type")) w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } ```
- 1.Use io.Copy with io.Discard for bodies you don't need: Discard unnecessary response data.
- 2.```go
- 3.func healthCheck() error {
- 4.resp, err := http.Get("http://upstream/health")
- 5.if err != nil {
- 6.return err
- 7.}
- 8.defer resp.Body.Close()
// Drain the body even if we don't need the content io.Copy(io.Discard, resp.Body)
if resp.StatusCode != http.StatusOK { return fmt.Errorf("unhealthy: %d", resp.StatusCode) } return nil } ```
Prevention
- Always defer resp.Body.Close() in middleware and proxy code
- Use io.Copy(io.Discard, resp.Body) to drain bodies you don't need
- Run goleak in tests that involve HTTP middleware
- Monitor goroutine count in production proxy and gateway applications