⏱️ 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
  1. TL;DR
  2. What is HTTP connection reuse?
  3. Why TCP connection recycling matters
  4. How HTTP keep-alive works by version
  5. Where the TCP connection pool fails in HTTP/1
  6. Practical examples and getting started
    1. Example 1: the function that abandons the connection
    2. Example 2: forcing reuse by hand
    3. Example 3: the case of partially-read JSON
    4. Verification: confirming whether a connection was reused
  7. Real use cases where this matters
  8. Common mistakes and best practices
  9. Comparison with alternatives
  10. Going deeper: how net/http decides whether to reuse a connection
  11. Frequently Asked Questions
    1. What’s the difference between HTTP/1.0 keep-alive and HTTP/1.1 persistence?
    2. Why does HTTP connection reuse fail if I don’t read the full body?
    3. Does HTTP/2 suffer from the same problem as HTTP/1.1 persistent connections?
    4. How do I check if Go is doing HTTP keep-alive in my program?
    5. Do I need Go 1.27 for my TCP connection pool to work well in production?
  12. 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
Between different regions, one-way latency alone usually exceeds 50 ms. Foto de User_Pascal en Unsplash

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.

VersionHow it keeps the connectionWhat breaks it
HTTP/1.0Optional keep-alive via the Connection: keep-alive headerOmitting the header, or an explicit Connection: close
HTTP/1.1Persistent by default, sequential responses on the same socketA response body not read all the way through, or Connection: close
HTTP/2Multiple multiplexed streams over a single connectionAlmost 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 use io.Copy(io.Discard, resp.Body) without a limit: a huge or never-ending body can hang your goroutine. Use io.CopyN with a cap, or wrap the body with http.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.

The default value of MaxIdleConnsPerHost in net/http is just 2 connections per host. Foto de Miguel Ángel Padriñán Alba en Unsplash

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 Body frees resources, but it doesn’t tell net/http that you consumed the content. These are two different things.
  • Decoding JSON and not draining the rest: as we saw in Example 3, json.Decoder can 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.

OptionWhen to use itAdvantageLimitation
Upgrade to Go 1.27+Projects that already control their Go version in productionFixes the problem for all existing code without touching itDoesn’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 controlWorks on any Go versionYou have to remember to do it on every call
Force HTTP/2When the server supports it and you control both sidesThe problem doesn’t exist: streams don’t block the connectionNot all servers or intermediate proxies support it equally
Client with its own poolVery high loads where the default Transport isn’t enoughFine-grained control over the connection poolYou 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.

📬 Get new articles by email

We only email about big articles (1-2 a month).

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

📱 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
Categories: ProgrammingTutorials

Andrés Morales

Developer and AI researcher. Writes about language models, frameworks, developer tooling, and open source releases. Covers ML papers, the tech startup ecosystem, and programming trends.

0 Comments

Leave a Reply

Avatar placeholder

Your email address will not be published. Required fields are marked *

You can include code inside <code>…</code> or, for several lines, <pre><code>…</code></pre>.

This site uses Akismet to reduce spam. Learn how your comment data is processed.