logo
Kurashizu Blogwhere ideas flow
  • Home
  • News
  • Blog
  • About
  • Home
  • News
  • Blog
  • About

© 2026 Kurashizu. All rights reserved.

Admin·New Post·Service Status
Powered by Cloudflare·30.10.26
← Back to news

Better HTTP/1 connection reuse in Go 1.27

Original article
go.dev|54 points|3 comments|by allddd|Oct 14, 2026

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:

  1. TCP Handshake: The client sends SYN, the server responds with SYN-ACK, and the client finishes with ACK. This costs 1 RTT.
  2. TLS Handshake: Encryption keys are exchanged and the server's identity is verified. In TLS 1.2, this requires 2 RTT.
  3. HTTP Request: Only after these steps can the actual request be transmitted.

The total setup time can be expressed as: Total Setup=TCP RTT+TLS RTT≈3×RTT\text{Total Setup} = \text{TCP RTT} + \text{TLS RTT} \approx 3 \times \text{RTT}Total Setup=TCP RTT+TLS RTT≈3×RTT

Impact of Latency

In cross-region scenarios, a one-way trip might be 50ms50\text{ms}50ms, meaning 1 RTT=100ms1\text{ RTT} = 100\text{ms}1 RTT=100ms. In such cases, you spend ∼300ms\sim 300\text{ms}∼300ms 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 VersionReuse MechanismDefault Behavior
HTTP/1.0Connection: keep-alive headerOpt-in
HTTP/1.1Persistent connectionsEnabled by default
HTTP/2Multiplexing streamsSingle 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/http realizes the connection is "dirty." Since it cannot find the start of the next response, it reuses abandons 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 StatusCode and ignores the body.
  • Partial reads: When a program reads most of the body but stops early.
  • Trailing whitespace: encoding/json.Decoder stops 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.