← posts / ai integrations

710 ms to 150 ms for 100 requests: what Go 1.27's HTTP/1 connection reuse fixes, and the body-draining habit to keep for older clients

Go 1.27 drains unread HTTP/1 response bodies for you, so a closed-but-unread body no longer kills connection reuse. A runnable test that counts reused connections, the two patterns that broke reuse, and a capped drain helper for Go 1.26 and older.

if.codesOct 17, 2026 · 6 min read#go#http#performance#networkingAI-assisted

If your Go service calls an LLM API, a webhook receiver or any REST endpoint over HTTP/1, there is a good chance it has been opening a fresh TCP and TLS connection for almost every request. The cause is small: a response body you closed without reading to the end. On October 14 the Go team (Nicholas Husin and Damien Neil) wrote up why this happens and what Go 1.27 changes. In their benchmark, 100 calls to index.golang.org over HTTP/1 took about 710 ms on Go 1.26 and about 150 ms on Go 1.27.

This post gives you a program that shows the problem on your own machine, the two patterns that cause it, and what to do if you can't move to Go 1.27 yet.

Why an unread body breaks reuse

HTTP/1 sends responses back to back on the same connection. The client can only find where the next response starts once it has read the current body to the very end. If you call resp.Body.Close() while bytes are still unread, older versions of net/http can't safely reuse the connection, so they close it. Your next request pays for a new TCP handshake and, on HTTPS, a new TLS handshake.

An unread response body acts as a blockage that prevents the connection from being reused.
An unread response body acts as a blockage that prevents the connection from being reused.

The Go blog names two common ways this happens:

  • Ignoring the body. You only care about the status code, but the server still sends something like {"success": true, "message": "update processed"}.
  • Stopping just short of the end. json.NewDecoder(resp.Body).Decode(&v) stops after the closing }. A trailing newline after the JSON object is still unread, and that is enough.

The first pattern looks completely innocent:

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
}

What Go 1.27 changes

From Go 1.27, net/http.Transport drains an unread HTTP/1 response body itself when you close it. Per the Go blog, the drain:

Go 1.27 significantly reduces latency by eliminating redundant handshakes.
Go 1.27 significantly reduces latency by eliminating redundant handshakes.
  • runs asynchronously in the background, so Close() doesn't block;
  • gives up after 50 ms or 256 KB, whichever comes first, so a huge or slow body doesn't tie up the connection;
  • is skipped when the response carries Connection: close, since that connection can't be reused anyway.

So getStatus above now reuses connections with no code change. This is the result of their 100-request benchmark:

0 ms200 ms400 ms600 ms800 msGo 1.26 · Total time: 710 ms710 msGo 1.26Go 1.27 · Total time: 150 ms150 msGo 1.27
100 sequential HTTP/1 calls to index.golang.org Approximate figures as published; HTTP/2 disabled with GODEBUG=http2client=0 Source: The Go Blog, Oct 14 2026
100 sequential HTTP/1 calls to index.golang.org
Total time
Go 1.26710 ms
Go 1.27150 ms

Roughly 5x, and all of it comes from not redoing handshakes. The further away the server is, the bigger the gap: every new connection costs at least one extra round trip for TCP plus more for TLS.

See it on your machine

This program makes sequential GET requests and counts how many went out on a reused connection, using httptrace.GotConnInfo.Reused. With no -url it starts a local test server that returns a small JSON body with a trailing newline. -drain reads the body to EOF before closing.

package main

import (
    "flag"
    "fmt"
    "io"
    "net/http"
    "net/http/httptest"
    "net/http/httptrace"
    "time"
)

func main() {
    url := flag.String("url", "", "URL to call (default: a local test server)")
    n := flag.Int("n", 100, "number of sequential requests")
    drain := flag.Bool("drain", false, "read the body to EOF before closing")
    flag.Parse()

    if *url == "" {
        srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            w.Header().Set("Content-Type", "application/json")
            fmt.Fprintln(w, `{"success": true, "message": "update processed"}`)
        }))
        defer srv.Close()
        *url = srv.URL
    }

    reused := 0
    trace := &httptrace.ClientTrace{
        GotConn: func(info httptrace.GotConnInfo) {
            if info.Reused {
                reused++
            }
        },
    }

    start := time.Now()
    for i := 0; i < *n; i++ {
        req, err := http.NewRequest("GET", *url, nil)
        if err != nil {
            panic(err)
        }
        req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
        resp, err := http.DefaultClient.Do(req)
        if err != nil {
            panic(err)
        }
        if *drain {
            io.Copy(io.Discard, resp.Body)
        }
        resp.Body.Close()
    }
    fmt.Printf("%d requests in %v, %d on a reused connection\n",
        *n, time.Since(start).Round(time.Millisecond), reused)
}

Save it as main.go in an empty directory and run it four ways. The last two hit the same host as the Go team's benchmark, with HTTP/2 turned off so you're testing HTTP/1:

go run main.go
go run main.go -drain
GODEBUG=http2client=0 go run main.go -url https://index.golang.org/index
GODEBUG=http2client=0 go run main.go -drain -url https://index.golang.org/index

This is what we got on Go 1.26.2 on a MacBook in Europe:

100 requests in 22ms, 0 on a reused connection
100 requests in 9ms, 99 on a reused connection
100 requests in 8.09s, 0 on a reused connection
100 requests in 1.638s, 99 on a reused connection

Without draining, not a single request reused a connection. Against a real HTTPS host that cost about 5x in wall time, the same ratio the Go blog reports. Our absolute numbers are higher than theirs because of the distance to the server and because /index returns a much larger body than a status endpoint. Run the same program under Go 1.27 and the no-drain case should come in close to the drained one, as long as the body fits under the 256 KB and 50 ms limits.

Want AI wired into the systems you already run?I build LLM integrations with costs and quality you can see. The estimate is free.

On Go 1.26 or older: drain it yourself

Until every service you run is on Go 1.27, keep the old habit: read what's left of the body before closing it, with a cap so a large or slow response doesn't keep you reading forever. This mirrors the limit Go 1.27 picked:

Manually draining the response body ensures the connection returns to the pool.
Manually draining the response body ensures the connection returns to the pool.
// drainAndClose reads up to 256 KB of whatever is left in body, then closes it.
// If the body is fully read, the connection goes back to the pool for reuse.
func drainAndClose(body io.ReadCloser) {
    io.CopyN(io.Discard, body, 256<<10)
    body.Close()
}

Use it in place of defer resp.Body.Close() wherever you don't read the body to EOF yourself, including after json.Decoder.Decode:

resp, err := http.Get(url)
if err != nil {
    return err
}
defer drainAndClose(resp.Body)

var out Result
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
    return err
}

A few notes on this helper:

  • If more than 256 KB is left, the connection is still closed. That's deliberate: reading megabytes just to save a handshake is a bad trade.
  • Unlike the Go 1.27 drain, this one runs synchronously, so a slow server can delay your return. If that matters, put a deadline on the request with context.WithTimeout.
  • HTTP/2 multiplexes streams on one connection and isn't affected by this. You will only see the problem on HTTP/1 connections: plain HTTP, servers or proxies that don't offer h2, and custom transports with HTTP/2 off.

What to check in your code

  • Search for defer resp.Body.Close() in handlers that only look at resp.StatusCode. Those are the most likely offenders.
  • Look for json.NewDecoder(resp.Body).Decode with nothing reading the rest of the body afterwards.
  • Run the trace above (or add the GotConn hook to a staging client) against your real upstreams. A reuse count near zero on sequential calls is the symptom.
  • When you upgrade to Go 1.27, keep the helper in shared client code for a while. It costs nothing when the body is already at EOF.

Sources

if.codesI build RAG, AI integrations and agent pipelines on Go and Python backends — and write about it here.
// keep reading

More posts on AI and backends.

// free quote

Read something you need? I’ll quote it for free.

RAG, AI integrations, agents or the backend underneath — tell me what you have and what should change. I read every request myself.

Free · no commitment

Tell me what you have. I’ll tell you what it takes.

1Describe the projectA few sentences is enough — about two minutes.
2I review itI read it myself and may ask a follow-up question.
3You get a free quoteScope, approach and estimate — yours to keep, no strings.
What kind of project is it?
Free and without obligation. Your details are used only to reply — see the privacy policy.