Go / Gin

How to Resolve "http: panic serving: connection reset by peer" in Gin

5 min read by DebuggedIt

Quick answer

Your Gin server logs a panic whenever a client disconnects mid-request instead of handling it quietly. "Connection reset by peer" means the client closed the...

Your Gin server logs a panic whenever a client disconnects mid-request instead of handling it quietly. "Connection reset by peer" means the client closed the TCP connection before your server finished writing the response β€” and without proper handling, Go's HTTP server surfaces that as a panic in your logs.

The Problem

The server log shows a panic with a broken pipe or connection reset error buried in the stack trace:

2026/08/07 14:12:03 http: panic serving [::1]:54821: write tcp 127.0.0.1:8080->127.0.0.1:54821: write: connection reset by peer
goroutine 812 [running]:
net/http.(*conn).serve.func1()
	/usr/local/go/src/net/http/server.go:1854 +0xb0
panic({0x9f7b40, 0xc0004a2000})
	/usr/local/go/src/runtime/panic.go:914 +0x21f
net/http.(*response).write(...)
	/usr/local/go/src/net/http/server.go:1710

It often correlates with slow endpoints β€” long-running requests, large file downloads, or streaming responses β€” where the client (a browser tab closed, a mobile app backgrounded, a load balancer health check timing out) gives up before your handler finishes writing.

Client disconnects mid-write Client Gin handler request tab closed / timeout / retry server still writing -> connection reset by peer gin.Recovery() catches it safely

Why It Happens

When a client closes its connection before the server finishes sending a response, the next write attempt from the server fails at the OS/TCP level with ECONNRESET. Go's net/http package treats this as a runtime error and panics inside the goroutine handling that specific request. Without middleware to catch it, that panic can crash the whole process depending on how your server is set up. Typical triggers:

  • A user closes a browser tab or navigates away while a request is still in flight.
  • A mobile client is backgrounded by the OS mid-request, killing the network connection abruptly.
  • A reverse proxy or load balancer has a shorter timeout than your handler's processing time, and closes the upstream connection first.
  • The client is a script or another service that retried the request and abandoned the original connection.
  • You're streaming a large response (file download, SSE, chunked transfer) and the client stops reading partway through.

The Fix

The immediate fix is making sure Gin's recovery middleware is active, which catches the panic and returns a 500 to a connection that, in this specific case, no longer exists to receive it β€” but critically, it prevents the panic from crashing the entire server process:

router := gin.New()
router.Use(gin.Recovery())

For more control, use a custom recovery handler so you can distinguish a genuine bug from an expected "client went away" event and avoid polluting your error logs with noise for something that isn't actually a bug:

router.Use(gin.CustomRecoveryWithWriter(io.Discard, func(c *gin.Context, recovered any) {
    if err, ok := recovered.(error); ok && isBrokenPipe(err) {
        c.AbortWithStatus(http.StatusOK) // client is gone, nothing more to do
        return
    }
    log.Printf("panic recovered: %v", recovered)
    c.AbortWithStatus(http.StatusInternalServerError)
}))

func isBrokenPipe(err error) bool {
    return strings.Contains(err.Error(), "broken pipe") ||
        strings.Contains(err.Error(), "connection reset by peer")
}

For handlers that stream large responses, check the request context periodically so you can stop working the moment the client disconnects instead of continuing to write into a dead connection:

func StreamData(c *gin.Context) {
    for _, chunk := range largeDataset {
        select {
        case <-c.Request.Context().Done():
            return // client disconnected, stop generating data
        default:
            c.Writer.Write(chunk)
            c.Writer.Flush()
        }
    }
}

Still Not Working?

If this is happening frequently even for normal, fast requests, check whether a load balancer or reverse proxy in front of Gin has a timeout shorter than your typical response time β€” this causes the proxy to close connections mid-response constantly, which looks identical to a client closing a browser tab. Compare your proxy's timeout setting against your slowest realistic endpoint:

# nginx example
proxy_read_timeout 60s;
proxy_send_timeout 60s;

If requests routinely take longer than that, either raise the proxy timeout or optimize the slow endpoint β€” the panic is a symptom of the mismatch, not the root cause.

It's also worth adding structured logging specifically for these events so they don't drown out genuine application errors in your monitoring. A recovered "connection reset by peer" panic isn't usually actionable the same way a real bug is β€” it just means a client left β€” so routing it to a lower log level or a separate metric keeps your alerting focused on problems you can actually fix:

router.Use(gin.CustomRecoveryWithWriter(io.Discard, func(c *gin.Context, recovered any) {
    if err, ok := recovered.(error); ok && isBrokenPipe(err) {
        clientDisconnectCounter.Inc() // metric, not an error log
        return
    }
    log.Printf("unexpected panic: %v", recovered)
    c.AbortWithStatus(http.StatusInternalServerError)
}))

If you're seeing this specifically on file downloads or large payload uploads, consider whether your handler is checking c.Request.Context().Err() periodically during long-running writes, since that lets you exit cleanly the moment a client disconnects instead of continuing to burn CPU and memory generating a response nobody will ever receive. This matters more than it might seem for services under real load β€” every abandoned request that keeps running to completion anyway is wasted work competing with requests from clients still actually waiting.