{"article":{"slug":"better-http-1-connection-reuse","title":"Better HTTP/1 connection reuse","subtitle":null,"summary":"The Go team explains why HTTP/1 connections often fail to be reused when response bodies are closed without being drained, how costly that is, the tricky manual workarounds developers have used, and how Go 1.27's net/http Transport now drains unread bodies automatically so closing the body is enough.","content_type":"blog_post","language":"en","canonical_url":"https://go.dev/blog/http-connection-reuse","author":{"name":"Nicholas Husin and Damien Neil","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"The Go Blog","url":"https://go.dev/blog/","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Networking","slug":"networking","url":"https://listedarticles.com/topics/networking"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1453,"reading_minutes":6,"published_at":"2026-10-14T00:00:00.000Z","added_at":"2026-10-14T20:16:18.296Z","updated_at":"2026-10-14T20:16:18.296Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/better-http-1-connection-reuse","markdown_url":"https://listedarticles.com/articles/better-http-1-connection-reuse.md","example":false,"citation":"Nicholas Husin and Damien Neil, The Go Blog. \"Better HTTP/1 connection reuse.\" 14 Oct 2026. https://go.dev/blog/http-connection-reuse (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://go.dev/blog/http-connection-reuse"},"body_markdown":"Take a look at the following function:\n\n```\nfunc getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    defer resp.Body.Close()\n    return resp.StatusCode, nil\n}\n```\nIt sends a `GET` request to `url` and returns the HTTP status code.\n\nUsing HTTP/1 (`GODEBUG=http2client=0`), calling this function 100 times against\n[index.golang.org](https://index.golang.org) takes ~710 milliseconds on Go 1.26\non my machine. On Go 1.27, the same program takes ~150 milliseconds.\n\nThat is almost 5 times faster! How is such a drastic performance difference possible?\n\n## The cost of establishing HTTP connections\n\nTo understand the difference, consider what happens on the network when we make an HTTP request.\n\nGo’s [`net/http`](https://go.dev/pkg/net/http) package supports HTTP/1.0, HTTP/1.1, and\nHTTP/2. All three run over TCP, and usually over TLS when talking to HTTPS\nendpoints. Making a request over a new connection looks something like this:\n\n1. **TCP handshake (1 RTT)** : the client sends a`SYN` packet, the server\nreplies with`SYN-ACK` , and the client replies with`ACK` . Because the\nclient can send the first TLS message alongside that final`ACK` ,\nestablishing the TCP connection takes 1 round-trip time (RTT).\n2. **TLS handshake (2 RTT)** : next, a TLS handshake exchanges encryption keys\nand verifies the server’s identity; using TLS 1.2, this takes 2 RTT.\n3. **HTTP request** : with the keys exchanged, HTTP requests can finally be sent\nafter a total of 3 RTT.\n\nThese round trips (plus some CPU time spent on TLS handshakes) add up quickly. In cross-region communications, one-way latencies often reach or exceed the 50 ms (100 ms RTT) shown in the diagram above. Compared to the 300 ms spent just setting up the connection, the HTTP request itself can be cheap to serve, taking only a little over 1 RTT when bandwidth is plentiful or the HTTP response is small.\n\nEven in more favorable conditions, opening a new connection can still be\nexpensive. Our opening benchmark queries\n[index.golang.org](https://index.golang.org), which is distributed worldwide and\nsupports TLS 1.3. This means that latency is relatively low, and the TLS\nhandshake can actually finish in just 1 RTT. Even so, the performance difference\nis still very noticeable.\n\n## Connection reuse\n\nTo avoid paying this setup cost on every request, HTTP clients and servers try to reuse existing connections across multiple requests when possible. The mechanics of this differ based on the HTTP version:\n\n- **HTTP/1.0** : while not part of the official specification, clients and\nservers often opt into connection reuse by sending a`Connection: keep-alive` header. Reuse continues until either endpoint omits the header\nor sends a`Connection: close` header.\n- **HTTP/1.1** : connections are persistent by default, and can be reused for\nsubsequent requests and responses unless either side declares otherwise by\nsending a`Connection: close` header.\n- **HTTP/2** : multiple requests and responses can be multiplexed over a single\nTCP connection.\n\n## When connection reuse fails\n\nIn HTTP/1 (both HTTP/1.0 and HTTP/1.1), multiple requests and responses on the\nsame connection are sent sequentially on the wire. If two `GET` requests are\nsent to [index.golang.org](https://index.golang.org) over the same TCP\nconnection, the responses arrive as a single stream of bytes:\n\nAs shown in the diagram, [`net/http.Get`](https://go.dev/pkg/net/http#Get) reads and parses\nthe status line and headers from the byte stream to populate\n[`net/http.Response`](https://go.dev/pkg/net/http#Response), then returns immediately. The\nresponse body remains unread on the connection and is consumed only as the\ncaller reads from [`net/http.Response.Body`](https://go.dev/pkg/net/http#Response.Body).\n\nBecause responses are sent back-to-back on the same stream,\n[`net/http`](https://go.dev/pkg/net/http) can only find the start of the next response after\nthe current response body has been read to the very end (as determined by the\n`Content-Length` or `Transfer-Encoding` header).\n\nNow look back at our example program, where we never read `resp.Body`:\n\n```\nfunc getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    defer resp.Body.Close()\n    return resp.StatusCode, nil\n}\n```\nThrough Go 1.26, when `resp.Body.Close` is called, [`net/http`](https://go.dev/pkg/net/http)\nsees that there is still an unread response body. Realizing that it cannot reuse\nthe connection, it abandons the existing connection and creates new connections\nfor its next requests, causing it to repeatedly incur the cost of establishing\nnew connections.\n\n(HTTP/2 does not have this problem. Because it multiplexes streams of frames over a single connection rather than sending responses strictly back-to-back, an unread response body on one stream does not block other requests from sharing the connection.)\n\n## Where failed connection reuse hurts\n\nThis subtlety of HTTP/1 connection reuse can cause silent performance degradation in seemingly innocuous code.\n\nAs in our opening example, a client might only care about an API response’s\nstatus code. However, the API endpoint it is talking to might send bodies that\nprovide more detailed but ultimately unused information, such as `{\"success\": true, \"message\": \"update processed\"}`. This will unfortunately cause connection\nreuse to fail.\n\nEven programs that do read the response body can run into this issue if they\nstop just short of the end of the body. For example,\n[`encoding/json.Decoder`](https://go.dev/pkg/encoding/json#Decoder) stops reading as soon as\nit sees the closing `}` of a JSON object. If a server sends a JSON response\nterminated with a newline, decoding the JSON object will leave the newline still\nunread. The connection cannot be reused for a new request until that newline is\nconsumed. Thus, a healthy connection might end up being abandoned, even though\nit was only a single byte away from being reusable.\n\n## The manual solution\n\nThe unread body problem has been a long-standing sharp edge. Over the years, many Go developers came up with their own workarounds. Most commonly, this is done by draining the response body prior to closing it:\n\n```\nfunc getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    drainAndCloseBody(resp.Body)\n    return resp.StatusCode, nil\n}\nfunc drainAndCloseBody(body io.ReadCloser) {\n    io.Copy(io.Discard, body) // Drain the response body before closing it.\n    body.Close()\n}\n```\nWhile this works for small responses, if the unread body is large, establishing a new TCP connection can be faster than downloading and discarding the rest of the body. A better approach is to cap the number of bytes we are willing to drain:\n\n```\nfunc drainAndCloseBody(body io.ReadCloser) {\n    io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.\n    body.Close()\n}\n```\nHowever, a server might send a response body that trickles in slowly, so we also need to cap the time we spend draining:\n\n```\nfunc drainAndCloseBody(body io.ReadCloser) {\n    drainDone := make(chan struct{}, 1)\n    go func() {\n        io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.\n        drainDone <- struct{}{}\n    }()\n    // Only spend at most 50 ms trying to drain the response body.\n    select {\n    case <-drainDone:\n    case <-time.After(50 * time.Millisecond):\n        // If we reach the 50 ms timeout, body.Close will run and interrupt the\n        // io.CopyN, preventing goroutine leaks.\n    }\n    body.Close()\n}\n```\nOf course, we also do not want to block for up to 50 milliseconds when closing a response body. Instead, we can drain the body asynchronously in the background and let it race against subsequent requests:\n\n```\nfunc drainAndCloseBody(body io.ReadCloser) {\n    drainDone := make(chan struct{}, 1)\n    go func() {\n        io.CopyN(io.Discard, body, 256<<10) // Only drain up to 256 KB.\n        drainDone <- struct{}{}\n    }()\n    go func() {\n        // Only spend at most 50 ms trying to drain the response body.\n        select {\n        case <-drainDone:\n            // If the drain can finish before a new request is sent, the\n            // connection will be reused; otherwise, a new connection is\n            // created, and the current connection will be put in a pool of\n            // idle connections, potentially being reused for another future\n            // request.\n        case <-time.After(50 * time.Millisecond):\n            // If we reach the 50 ms timeout, body.Close will run and interrupt\n            // the io.CopyN, preventing goroutine leaks.\n        }\n        body.Close()\n    }()\n}\n```\nAs we can see, draining response bodies safely is surprisingly tricky and cumbersome. Worse, as the practice of draining response bodies spread, some nuances were often lost. This has led to misunderstandings such as attempting to drain response bodies for HTTP/2 connections, when doing so is unnecessary.\n\n## The current state and conclusion\n\nStarting in Go 1.27, [`net/http.Transport`](https://go.dev/pkg/net/http#Transport)\nautomatically does everything we have implemented in the code above: it will\nasynchronously drain an unread HTTP/1 response body for up to 50 ms or 256 KB.\nIt also implements additional optimizations; for example, when a response\ncontains the `Connection: close` header, draining the body is guaranteed to be\nwasted work, and it therefore skips doing so. Note that this behavior might\nevolve in future versions of Go, as other possible enhancements become apparent.\n\nAs a general best practice, we now recommend that users simply close their HTTP bodies once they no longer need them, like in our original example:\n\n```\nfunc getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    defer resp.Body.Close()\n    return resp.StatusCode, nil\n}\n```\nGo is all about simplicity. Let your code stay clean and simple, and let\n[`net/http`](https://go.dev/pkg/net/http) deal with the complexities on your behalf.\n","body_html":"<p>Take a look at the following function:</p>\n<pre><code>func getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    defer resp.Body.Close()\n    return resp.StatusCode, nil\n}</code></pre>\n<p>It sends a <code>GET</code> request to <code>url</code> and returns the HTTP status code.</p>\n<p>Using HTTP/1 (<code>GODEBUG=http2client=0</code>), calling this function 100 times against\n<a href=\"https://index.golang.org\" rel=\"nofollow ugc noopener\">index.golang.org</a> takes ~710 milliseconds on Go 1.26\non my machine. On Go 1.27, the same program takes ~150 milliseconds.</p>\n<p>That is almost 5 times faster! How is such a drastic performance difference possible?</p>\n<h2 id=\"the-cost-of-establishing-http-connections\">The cost of establishing HTTP connections</h2>\n<p>To understand the difference, consider what happens on the network when we make an HTTP request.</p>\n<p>Go’s <a href=\"https://go.dev/pkg/net/http\" rel=\"nofollow ugc noopener\"><code>net/http</code></a> package supports HTTP/1.0, HTTP/1.1, and\nHTTP/2. All three run over TCP, and usually over TLS when talking to HTTPS\nendpoints. Making a request over a new connection looks something like this:</p>\n<ol><li><p><strong>TCP handshake (1 RTT)</strong> : the client sends a<code>SYN</code> packet, the server</p><p>replies with<code>SYN-ACK</code> , and the client replies with<code>ACK</code> . Because the\nclient can send the first TLS message alongside that final<code>ACK</code> ,\nestablishing the TCP connection takes 1 round-trip time (RTT).</p></li><li><p><strong>TLS handshake (2 RTT)</strong> : next, a TLS handshake exchanges encryption keys</p><p>and verifies the server’s identity; using TLS 1.2, this takes 2 RTT.</p></li><li><p><strong>HTTP request</strong> : with the keys exchanged, HTTP requests can finally be sent</p><p>after a total of 3 RTT.</p></li></ol>\n<p>These round trips (plus some CPU time spent on TLS handshakes) add up quickly. In cross-region communications, one-way latencies often reach or exceed the 50 ms (100 ms RTT) shown in the diagram above. Compared to the 300 ms spent just setting up the connection, the HTTP request itself can be cheap to serve, taking only a little over 1 RTT when bandwidth is plentiful or the HTTP response is small.</p>\n<p>Even in more favorable conditions, opening a new connection can still be\nexpensive. Our opening benchmark queries\n<a href=\"https://index.golang.org\" rel=\"nofollow ugc noopener\">index.golang.org</a>, which is distributed worldwide and\nsupports TLS 1.3. This means that latency is relatively low, and the TLS\nhandshake can actually finish in just 1 RTT. Even so, the performance difference\nis still very noticeable.</p>\n<h2 id=\"connection-reuse\">Connection reuse</h2>\n<p>To avoid paying this setup cost on every request, HTTP clients and servers try to reuse existing connections across multiple requests when possible. The mechanics of this differ based on the HTTP version:</p>\n<ul><li><p><strong>HTTP/1.0</strong> : while not part of the official specification, clients and</p><p>servers often opt into connection reuse by sending a<code>Connection: keep-alive</code> header. Reuse continues until either endpoint omits the header\nor sends a<code>Connection: close</code> header.</p></li><li><p><strong>HTTP/1.1</strong> : connections are persistent by default, and can be reused for</p><p>subsequent requests and responses unless either side declares otherwise by\nsending a<code>Connection: close</code> header.</p></li><li><p><strong>HTTP/2</strong> : multiple requests and responses can be multiplexed over a single</p><p>TCP connection.</p></li></ul>\n<h2 id=\"when-connection-reuse-fails\">When connection reuse fails</h2>\n<p>In HTTP/1 (both HTTP/1.0 and HTTP/1.1), multiple requests and responses on the\nsame connection are sent sequentially on the wire. If two <code>GET</code> requests are\nsent to <a href=\"https://index.golang.org\" rel=\"nofollow ugc noopener\">index.golang.org</a> over the same TCP\nconnection, the responses arrive as a single stream of bytes:</p>\n<p>As shown in the diagram, <a href=\"https://go.dev/pkg/net/http#Get\" rel=\"nofollow ugc noopener\"><code>net/http.Get</code></a> reads and parses\nthe status line and headers from the byte stream to populate\n<a href=\"https://go.dev/pkg/net/http#Response\" rel=\"nofollow ugc noopener\"><code>net/http.Response</code></a>, then returns immediately. The\nresponse body remains unread on the connection and is consumed only as the\ncaller reads from <a href=\"https://go.dev/pkg/net/http#Response.Body\" rel=\"nofollow ugc noopener\"><code>net/http.Response.Body</code></a>.</p>\n<p>Because responses are sent back-to-back on the same stream,\n<a href=\"https://go.dev/pkg/net/http\" rel=\"nofollow ugc noopener\"><code>net/http</code></a> can only find the start of the next response after\nthe current response body has been read to the very end (as determined by the\n<code>Content-Length</code> or <code>Transfer-Encoding</code> header).</p>\n<p>Now look back at our example program, where we never read <code>resp.Body</code>:</p>\n<pre><code>func getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    defer resp.Body.Close()\n    return resp.StatusCode, nil\n}</code></pre>\n<p>Through Go 1.26, when <code>resp.Body.Close</code> is called, <a href=\"https://go.dev/pkg/net/http\" rel=\"nofollow ugc noopener\"><code>net/http</code></a>\nsees that there is still an unread response body. Realizing that it cannot reuse\nthe connection, it abandons the existing connection and creates new connections\nfor its next requests, causing it to repeatedly incur the cost of establishing\nnew connections.</p>\n<p>(HTTP/2 does not have this problem. Because it multiplexes streams of frames over a single connection rather than sending responses strictly back-to-back, an unread response body on one stream does not block other requests from sharing the connection.)</p>\n<h2 id=\"where-failed-connection-reuse-hurts\">Where failed connection reuse hurts</h2>\n<p>This subtlety of HTTP/1 connection reuse can cause silent performance degradation in seemingly innocuous code.</p>\n<p>As in our opening example, a client might only care about an API response’s\nstatus code. However, the API endpoint it is talking to might send bodies that\nprovide more detailed but ultimately unused information, such as <code>{&quot;success&quot;: true, &quot;message&quot;: &quot;update processed&quot;}</code>. This will unfortunately cause connection\nreuse to fail.</p>\n<p>Even programs that do read the response body can run into this issue if they\nstop just short of the end of the body. For example,\n<a href=\"https://go.dev/pkg/encoding/json#Decoder\" rel=\"nofollow ugc noopener\"><code>encoding/json.Decoder</code></a> stops reading as soon as\nit sees the closing <code>}</code> of a JSON object. If a server sends a JSON response\nterminated with a newline, decoding the JSON object will leave the newline still\nunread. The connection cannot be reused for a new request until that newline is\nconsumed. Thus, a healthy connection might end up being abandoned, even though\nit was only a single byte away from being reusable.</p>\n<h2 id=\"the-manual-solution\">The manual solution</h2>\n<p>The unread body problem has been a long-standing sharp edge. Over the years, many Go developers came up with their own workarounds. Most commonly, this is done by draining the response body prior to closing it:</p>\n<pre><code>func getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    drainAndCloseBody(resp.Body)\n    return resp.StatusCode, nil\n}\nfunc drainAndCloseBody(body io.ReadCloser) {\n    io.Copy(io.Discard, body) // Drain the response body before closing it.\n    body.Close()\n}</code></pre>\n<p>While this works for small responses, if the unread body is large, establishing a new TCP connection can be faster than downloading and discarding the rest of the body. A better approach is to cap the number of bytes we are willing to drain:</p>\n<pre><code>func drainAndCloseBody(body io.ReadCloser) {\n    io.CopyN(io.Discard, body, 256&lt;&lt;10) // Only drain up to 256 KB.\n    body.Close()\n}</code></pre>\n<p>However, a server might send a response body that trickles in slowly, so we also need to cap the time we spend draining:</p>\n<pre><code>func drainAndCloseBody(body io.ReadCloser) {\n    drainDone := make(chan struct{}, 1)\n    go func() {\n        io.CopyN(io.Discard, body, 256&lt;&lt;10) // Only drain up to 256 KB.\n        drainDone &lt;- struct{}{}\n    }()\n    // Only spend at most 50 ms trying to drain the response body.\n    select {\n    case &lt;-drainDone:\n    case &lt;-time.After(50 * time.Millisecond):\n        // If we reach the 50 ms timeout, body.Close will run and interrupt the\n        // io.CopyN, preventing goroutine leaks.\n    }\n    body.Close()\n}</code></pre>\n<p>Of course, we also do not want to block for up to 50 milliseconds when closing a response body. Instead, we can drain the body asynchronously in the background and let it race against subsequent requests:</p>\n<pre><code>func drainAndCloseBody(body io.ReadCloser) {\n    drainDone := make(chan struct{}, 1)\n    go func() {\n        io.CopyN(io.Discard, body, 256&lt;&lt;10) // Only drain up to 256 KB.\n        drainDone &lt;- struct{}{}\n    }()\n    go func() {\n        // Only spend at most 50 ms trying to drain the response body.\n        select {\n        case &lt;-drainDone:\n            // If the drain can finish before a new request is sent, the\n            // connection will be reused; otherwise, a new connection is\n            // created, and the current connection will be put in a pool of\n            // idle connections, potentially being reused for another future\n            // request.\n        case &lt;-time.After(50 * time.Millisecond):\n            // If we reach the 50 ms timeout, body.Close will run and interrupt\n            // the io.CopyN, preventing goroutine leaks.\n        }\n        body.Close()\n    }()\n}</code></pre>\n<p>As we can see, draining response bodies safely is surprisingly tricky and cumbersome. Worse, as the practice of draining response bodies spread, some nuances were often lost. This has led to misunderstandings such as attempting to drain response bodies for HTTP/2 connections, when doing so is unnecessary.</p>\n<h2 id=\"the-current-state-and-conclusion\">The current state and conclusion</h2>\n<p>Starting in Go 1.27, <a href=\"https://go.dev/pkg/net/http#Transport\" rel=\"nofollow ugc noopener\"><code>net/http.Transport</code></a>\nautomatically does everything we have implemented in the code above: it will\nasynchronously drain an unread HTTP/1 response body for up to 50 ms or 256 KB.\nIt also implements additional optimizations; for example, when a response\ncontains the <code>Connection: close</code> header, draining the body is guaranteed to be\nwasted work, and it therefore skips doing so. Note that this behavior might\nevolve in future versions of Go, as other possible enhancements become apparent.</p>\n<p>As a general best practice, we now recommend that users simply close their HTTP bodies once they no longer need them, like in our original example:</p>\n<pre><code>func getStatus(url string) (int, error) {\n    resp, err := http.Get(url)\n    if err != nil {\n        return 0, err\n    }\n    defer resp.Body.Close()\n    return resp.StatusCode, nil\n}</code></pre>\n<p>Go is all about simplicity. Let your code stay clean and simple, and let\n<a href=\"https://go.dev/pkg/net/http\" rel=\"nofollow ugc noopener\"><code>net/http</code></a> deal with the complexities on your behalf.</p>","headings":[{"level":2,"text":"The cost of establishing HTTP connections","id":"the-cost-of-establishing-http-connections"},{"level":2,"text":"Connection reuse","id":"connection-reuse"},{"level":2,"text":"When connection reuse fails","id":"when-connection-reuse-fails"},{"level":2,"text":"Where failed connection reuse hurts","id":"where-failed-connection-reuse-hurts"},{"level":2,"text":"The manual solution","id":"the-manual-solution"},{"level":2,"text":"The current state and conclusion","id":"the-current-state-and-conclusion"}]}}