⏱️ Reading time: 16 min
A Go program that only requests the status code of a URL, one hundred times in a row, took 710 milliseconds on Go 1.26. With Go 1.27, the same program, without changing a single line, takes about 150 milliseconds: almost five times faster. The difference isn’t in the network, but in HTTP connection reuse: whether Go reuses the TCP connection it already had open or not.
📑 En este artículo
- TL;DR
- What is HTTP connection reuse?
- Why TCP connection recycling matters
- How HTTP keep-alive works by version
- Where the TCP connection pool fails in HTTP/1
- Practical examples and getting started
- Real use cases where this matters
- Common mistakes and best practices
- Comparison with alternatives
- Going deeper: how net/http decides whether to reuse a connection
- Frequently Asked Questions
- What’s the difference between HTTP/1.0 keep-alive and HTTP/1.1 persistence?
- Why does HTTP connection reuse fail if I don’t read the full body?
- Does HTTP/2 suffer from the same problem as HTTP/1.1 persistent connections?
- How do I check if Go is doing HTTP keep-alive in my program?
- Do I need Go 1.27 for my TCP connection pool to work well in production?
- References
This article explains what HTTP keep-alive is, why HTTP/1.1 breaks it so easily when the response body is left unread, and how to write code that takes advantage of the net/http connection pool, even in Go versions before 1.27.
TL;DR
- Reopening a TCP and TLS connection on every request costs up to 3 full RTTs before sending the first useful byte.
- HTTP/1.1 keeps the connection open by default, but only if the client reads the response body all the way through.
- net/http used to abandon the connection when the caller ignored resp.Body, forcing a new handshake on every call.
- Go 1.27 drains the unread body in the background before closing, so it can still recycle the connection.
- io.Copy(io.Discard, resp.Body) before Close() achieves the same effect in earlier Go versions.
What is HTTP connection reuse?
HTTP connection reuse is the technique by which a client and a server keep the same TCP connection open, along with its TLS session if applicable, for several requests in a row instead of negotiating a new connection every time. It saves the cost of those handshakes, which usually exceeds the cost of the request itself.
In practice this means your HTTP client keeps already-established connections in a pool organized by host, and reuses them for the next request instead of repeating the whole connection process from scratch. When this mechanism fails without you noticing, the symptom isn’t an error: it’s simply that everything runs slower than expected.
Why TCP connection recycling matters
Before an HTTP client can send a single request over HTTPS, three things happen in order. First, the TCP handshake: the client sends a SYN, the server responds with SYN-ACK, and the client confirms with ACK. This consumes 1 RTT (round-trip time), because the client can send the first TLS message together with that final ACK.
Second, the TLS handshake: client and server exchange encryption keys and the server proves its identity. With TLS 1.2 this takes 2 additional RTTs; with TLS 1.3, faster, it drops to 1 RTT. Only after that can the first HTTP request be sent, adding up to 3 RTTs before receiving a single response.
These round-trips become more noticeable the farther away the server is. In cross-region communications, one-way latency usually sits around or above 50 ms, meaning 100 ms of RTT. Compared to the 300 ms of setting up the connection, the HTTP request itself can be cheap, just a bit more than 1 RTT if there’s bandwidth to spare. The Go team’s original benchmark used index.golang.org, a globally distributed service with TLS 1.3, so the baseline latency was already low. Even so, the difference between reusing the connection or not was fivefold.
sequenceDiagram
participant C as Client
participant S as Server
C->>S: SYN
S-->>C: SYN-ACK
C->>S: ACK and TLS ClientHello
Note over C,S: up to here, 1 RTT of TCP
S-->>C: ServerHello and certificate
C->>S: negotiated keys
Note over C,S: plus 1 or 2 RTT of TLS depending on version
C->>S: GET /index.html
S-->>C: 200 OK
How HTTP keep-alive works by version
The way a connection stays open changes with every version of the protocol. In HTTP/1.0, keep-alive was never officially part of the specification: client and server agreed on it by sending the Connection: keep-alive header, and the connection stayed alive until either side omitted it or sent Connection: close.
HTTP/1.1 flips that logic: connections are persistent by default. You can send several requests and receive several responses over the same socket without negotiating anything, unless either side declares otherwise with Connection: close.
HTTP/2 changes the entire model. Instead of sending responses one after another over the same byte stream, it multiplexes several frame streams over a single TCP connection. That’s exactly what avoids the problem you’ll see in the next section.
| Version | How it keeps the connection | What breaks it |
|---|---|---|
| HTTP/1.0 | Optional keep-alive via the Connection: keep-alive header | Omitting the header, or an explicit Connection: close |
| HTTP/1.1 | Persistent by default, sequential responses on the same socket | A response body not read all the way through, or Connection: close |
| HTTP/2 | Multiple multiplexed streams over a single connection | Almost nothing at the single-stream level: a stuck one doesn’t block the others |
Where the TCP connection pool fails in HTTP/1
In HTTP/1, responses travel one after another over the same byte stream. If you send two GET requests over the same connection, both responses arrive as a single continuous stream: there’s no marker separating where response 1 ends and response 2 begins, other than the content itself.
That’s why net/http can only find the start of the next response after reading the current response all the way through, which it determines via the Content-Length header or Transfer-Encoding: chunked. As long as the previous body hasn’t been fully read, there’s no safe way to know where the next one starts.
That’s exactly the problem with a function as simple as this one, taken from the official post:
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
This function never touches resp.Body. Up through Go 1.26, when resp.Body.Close() is called, net/http realizes there’s still unread body left on the socket. Since it can’t guarantee where it ends, it simply abandons the connection and opens a new one for the next call. If you call getStatus a hundred times, you pay for a hundred full handshakes.
sequenceDiagram
participant App as Application
participant Lib as net/http
participant Conn as TCP Connection
App->>Lib: http.Get(url)
Lib->>Conn: GET /status
Conn-->>Lib: status line and headers
Lib-->>App: returns Response immediately
Note over Lib,Conn: the response body is still unread on the socket
App->>Lib: resp.Body.Close()
Note over Lib,Conn: Go 1.26 abandons the connection without knowing where the body ends
Practical examples and getting started
You’ll need the Go toolchain installed. Any version 1.21 or later works for following these examples, but to see the automatic fix without extra tricks you need 1.27 or later. If you don’t have it yet, follow the official installation guide for your system: there are packages for Linux, macOS (also installable with brew install go), and Windows (.msi installer).
Confirm your version with:
go version
The output has this format: go version go1.27.0 linux/amd64.
Example 1: the function that abandons the connection
Save this in a file called main.go:
package main
import (
"fmt"
"net/http"
)
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
func main() {
status, err := getStatus("https://go.dev")
if err != nil {
panic(err)
}
fmt.Println("status:", status)
}
Run the program forcing HTTP/1, just like in the original benchmark:
GODEBUG=http2client=0 go run main.go
Expected output:
status: 200
If you call getStatus a hundred times in a loop with Go 1.26, each call opens a new connection. In Go 1.27, the client tries to drain the unread body before closing the connection, so several calls end up sharing the same socket.
Example 2: forcing reuse by hand
If you’re working with a Go version earlier than 1.27, you can achieve the same effect by reading the body before closing it:
package main
import (
"io"
"net/http"
)
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode, nil
}
io.Copy(io.Discard, resp.Body) reads all the remaining bytes of the body and discards them, without storing them in memory. That confirms to net/http where the response ends, and the connection goes back to the idle connection pool instead of closing.
⚠️ Watch out: if the server isn’t trusted, don’t useio.Copy(io.Discard, resp.Body)without a limit: a huge or never-ending body can hang your goroutine. Useio.CopyNwith a cap, or wrap the body withhttp.MaxBytesReader.
Example 3: the case of partially-read JSON
The problem also shows up with code that does read the body, but stops just short of the end. encoding/json.Decoder stops reading as soon as it sees the closing brace of the JSON object. If the server sends the response with a trailing newline, which is very common, that newline is left unread:
package main
import (
"encoding/json"
"io"
"net/http"
)
type statusResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
func getMessage(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out statusResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
io.Copy(io.Discard, resp.Body)
return out.Message, nil
}
The io.Copy(io.Discard, resp.Body) line after Decode consumes that leftover newline. Without it, even though your code already read the full JSON, the connection is left with a pending byte and can’t be recycled.
Verification: confirming whether a connection was reused
To check this without guessing, net/http/httptrace exposes a GotConn callback with a Reused field:
package main
import (
"fmt"
"io"
"net/http"
"net/http/httptrace"
)
func main() {
client := &http.Client{}
for i := 0; i < 3; i++ {
req, _ := http.NewRequest("GET", "https://go.dev", nil)
trace := &httptrace.ClientTrace{
GotConn: func(info httptrace.GotConnInfo) {
fmt.Println("connection reused:", info.Reused)
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
resp, _ := client.Do(req)
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
Expected output:
connection reused: false
connection reused: true
connection reused: true
The first call always opens a new connection. If you drain the body before closing, the second and third calls reuse it. The Reused field is documented in the net/http/httptrace package.
Real use cases where this matters
The most common pattern is a client that only cares about the status code of a health check: a load balancer, a service mesh sidecar, or a script monitoring whether an internal endpoint responds. If that check runs every few seconds and never reads the body, every round pays the full cost of opening a connection.
Another typical case is internal REST API clients that discard the body of write responses, like a PUT or a DELETE that returns {"success": true} and nothing else. The code only needs the status code, so it’s easy to forget to read that small but present body.
It also happens with outgoing webhooks: a service that notifies dozens of HTTP subscribers and only checks that each one responded with 200, regardless of what it returned. If those subscribers are in another region, every lost connection costs the same RTTs described in the previous section, multiplied by the number of notifications.
Common mistakes and best practices
- Ignoring the body because you don’t need it: even if your logic doesn’t use the body, you still need to drain it. The cost of an
io.Copy(io.Discard, ...)is tiny compared to a new handshake. - Confusing Close() with already read everything: closing the
Bodyfrees resources, but it doesn’t tellnet/httpthat you consumed the content. These are two different things. - Decoding JSON and not draining the rest: as we saw in Example 3,
json.Decodercan leave bytes unread even when your struct is already complete. - Not checking the connection pool size: if your service makes many concurrent requests to the same host, the default value of
Transport.MaxIdleConnsPerHost(2) may be insufficient and force handshakes anyway. - Measuring without ruling out the HTTP/2 effect: if your client negotiates HTTP/2 automatically, you won’t see this problem because multiplexed streams don’t suffer from it. To compare Go versions fairly, force HTTP/1 with
GODEBUG=http2client=0.
Comparison with alternatives
Updating your Go version isn’t the only way to fix this. It depends on how much control you have over your code and over the server on the other side.
| Option | When to use it | Advantage | Limitation |
|---|---|---|---|
| Upgrade to Go 1.27+ | Projects that already control their Go version in production | Fixes the problem for all existing code without touching it | Doesn’t help if the server uses HTTP/1.0 without keep-alive |
| Manual io.Copy(io.Discard, resp.Body) | Go versions before 1.27, or libraries you don’t control | Works on any Go version | You have to remember to do it on every call |
| Force HTTP/2 | When the server supports it and you control both sides | The problem doesn’t exist: streams don’t block the connection | Not all servers or intermediate proxies support it equally |
| Client with its own pool | Very high loads where the default Transport isn’t enough | Fine-grained control over the connection pool | You lose direct compatibility with the standard net/http ecosystem |
Going deeper: how net/http decides whether to reuse a connection
Internally, net/http.Transport keeps a pool of idle connections organized by host. When you call resp.Body.Close(), the Transport decides whether that connection goes back to the pool or closes for good.
That decision depends on two things: whether net/http can determine with certainty where the response body ends, via Content-Length or the final frame of Transfer-Encoding: chunked, and whether those bytes have already been consumed. Up through Go 1.26, it was enough for bytes to remain unread for the connection to be discarded immediately, without trying anything else.
Go 1.27 changes exactly that point: instead of abandoning the connection as soon as it detects an unread body, net/http tries to read and discard those bytes before deciding it can’t be reused, as described in the Go team’s official post. This means successful HTTP connection reuse no longer depends on the application draining the body by hand.
HTTP/2 never had this problem because it doesn’t send responses over a single sequential stream: it multiplexes frames from different streams over the same connection, so an unread body in one stream doesn’t block the others.
flowchart TD
A["resp.Body.Close() called"] --> B{"Body read to the end?"}
B -- "yes" --> C["Connection returns to the idle conns pool"]
B -- "no, Go 1.26" --> D["Connection is discarded and closed"]
B -- "no, Go 1.27" --> E["net/http drains the rest of the body"]
E --> C
💡 Tip: to reproduce the exact behavior of the original benchmark, run your tests with GODEBUG=http2client=0: that way you make sure you’re measuring HTTP/1 and not a client that already jumped to HTTP/2 on its own.
Your next step: time your own version of getStatus in a loop of 100 calls, with and without io.Copy(io.Discard, resp.Body), using time.Since, and compare the two times on your own network.
Frequently Asked Questions
What’s the difference between HTTP/1.0 keep-alive and HTTP/1.1 persistence?
In HTTP/1.0 you have to request it explicitly with the Connection: keep-alive header, and either side can cut it off at any time. In HTTP/1.1 connections are persistent by default: they stay open unless Connection: close is declared.
Why does HTTP connection reuse fail if I don’t read the full body?
Because in HTTP/1, responses travel one after another over the same byte stream. If you haven’t read the current body all the way through, net/http can’t know for certain where the next response starts, so it prefers to close the connection rather than risk reading mixed-up data.
Does HTTP/2 suffer from the same problem as HTTP/1.1 persistent connections?
No. HTTP/2 multiplexes several frame streams over a single TCP connection, so an unread body in one stream doesn’t block reading the others. The problem described in this article is specific to HTTP/1.
How do I check if Go is doing HTTP keep-alive in my program?
By using net/http/httptrace with a ClientTrace.GotConn that reads the Reused field of httptrace.GotConnInfo, as shown in the verification section above.
Do I need Go 1.27 for my TCP connection pool to work well in production?
Not necessarily. If you drain the response body with io.Copy(io.Discard, resp.Body) before closing it, net/http‘s idle connection pool works just the same in earlier versions. Go 1.27 simply does that work for you even if you forget.
References
- Go Blog: Better HTTP/1 connection reuse: the Go team’s original post with the benchmark and the explanation of the change in 1.27.
- pkg.go.dev: net/http package: official documentation for Transport, including the default value of MaxIdleConnsPerHost.
- pkg.go.dev: net/http/httptrace package: reference for the Reused field used in the verification section.
- MDN: Connection management in HTTP/1.x: explanation of keep-alive and persistent connections in HTTP/1.
- Wikipedia: HTTP persistent connection: historical context on keep-alive in HTTP/1.0 and HTTP/1.1.
📱 Enjoy this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Featured image: Foto de Kirill Sh en Unsplash
Did it work for you? Got a different error? Say so below: questions get answered and help the next reader.
Leave a comment
0 Comments