Better HTTP/1 connection reuse in Go 1.27
Enhancing HTTP/1 Connection Reuse in Go 1.27
By Nicholas Husin and Damien Neil — October 14, 2026
Consider the following implementation of a status checker:
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 retrieves the HTTP status code for a given url. When executing this 100 times against index.golang.org using HTTP/1 (via GODEBUG=http2client=0), the process takes approximately 710 milliseconds on Go 1.26.
The Overhead of Establishing Connections
To understand why this happens, we must examine the network layer. Go's net/http package supports several versions: HTTP/1.0, HTTP/1.1, and HTTP/2. All of these rely on TCP, typically wrapped in TLS for HTTPS.
The Handshake Process
Setting up a fresh connection involves several steps:
- TCP Handshake: The client sends
SYN, the server responds withSYN-ACK, and the client finishes withACK. This costs 1 RTT. - TLS Handshake: Encryption keys are exchanged and the server's identity is verified. In TLS 1.2, this requires 2 RTT.
- HTTP Request: Only after these steps can the actual request be transmitted.
The total setup time can be expressed as:
Impact of Latency
In cross-region scenarios, a one-way trip might be , meaning . In such cases, you spend just on the handshake, which often dwarfs the time it takes the server to actually process the request.
Even with TLS 1.3 (which reduces the handshake to 1 RTT) and low-latency distributed endpoints like index.golang.org, the performance penalty of creating new connections remains significant.
Mechanisms for Connection Reuse
To mitigate these costs, clients and servers attempt to keep connections open for multiple requests.
| HTTP Version | Reuse Mechanism | Default Behavior |
|---|---|---|
| HTTP/1.0 | Connection: keep-alive header | Opt-in |
| HTTP/1.1 | Persistent connections | Enabled by default |
| HTTP/2 | Multiplexing streams | Single TCP connection for many requests |
Why Connection Reuse Fails in HTTP/1
In HTTP/1, requests and responses are strictly sequential. If you send two requests over one connection, the responses arrive as a continuous byte stream.
When net/http.Get is called, it reads the status line and headers to build the net/http.Response object and returns it immediately. However, the response body is still sitting on the wire. The client can only identify the start of the next response once the current body has been read entirely (based on Content-Length or Transfer-Encoding).
The "Leaky" Connection
Refer back to our getStatus function. We call resp.Body.Close() without ever reading the body.
The Problem: In Go 1.26 and earlier, if
Close()is called while data remains unread in the body,net/httprealizes the connection is "dirty." Since it cannot find the start of the next response, itreusesabandons the connection and opens a brand new one for the next request.
Note: HTTP/2 avoids this because it uses frames to multiplex streams; an unread body on one stream doesn't block others.
Real-World Performance Degradation
This behavior often leads to silent performance drops in common coding patterns:
- Status-only checks: When a developer only needs the
StatusCodeand ignores the body. - Partial reads: When a program reads most of the body but stops early.
- Trailing whitespace:
encoding/json.Decoderstops at the closing}. If the server appends a newline (\n) after the JSON, that single byte remains unread.
Checklist of Reuse Killers:
- Ignoring the response body entirely.
- Closing the body before reading to the end.
- Using a JSON decoder that leaves trailing bytes.
The Manual Workaround
Because this has been a "sharp edge" for years, many developers use a manual "drain" pattern:
func getStatus(url string) (int, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
drainAndCloseBody(resp.Body)
return resp.StatusCode, nil
}
func drainAndCloseBody(body io.ReadCloser) {
// Explicitly consume all remaining data
io.Copy(io.Discard, body)
body.Close()
}
While this approach is effective, it requires boilerplate code for every single request to ensure the connection remains healthy.