Skip to content
gohacks.dev
Web

Connection Reuse and Keep Alive in the Go HTTP Client

See when the Go HTTP client reuses a connection and when it dials a new one, using httptrace, and how idle timeouts and Connection close change that.

7 min read
Summarize in AI
Flat illustration of a browser and a server trading a request and a response.

A fast HTTP call to a nearby service takes a millisecond or two. Opening a new connection to it takes about the same again for TCP, and several times that for TLS. So whether your client reuses connections decides whether a call costs one round trip or four. In Go the reuse happens automatically when conditions are right, silently fails when they are not, and has no log line either way. The tool that makes it visible is httptrace, and it turns a vague performance worry into an assertion.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article uses it to show five situations side by side. It builds on the HTTP client defaults, which explained why bodies must be drained, and it leads into transport settings and HTTP/2. The overview of the web cluster is a production HTTP server. Everything ran on Go 1.25.6.

Watching reuse with httptrace

net/http/httptrace lets you attach callbacks to a request's context. The GotConn callback fires when the transport has a connection for the request and says whether it was recycled (Reused), whether it had been sitting idle (WasIdle) and for how long (IdleTime). The program sends a few requests under different conditions and prints that one flag per request. Each scenario uses its own transport so the pools do not interfere.

main.goGo
package main

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

// fetch makes one request, draining and closing the body, and reports whether the connection was reused.
func fetch(c *http.Client, url string, closeAfter bool) (reused bool, serverSaidClose bool) {
	req, _ := http.NewRequest("GET", url, nil)
	req.Close = closeAfter
	trace := &httptrace.ClientTrace{GotConn: func(i httptrace.GotConnInfo) { reused = i.Reused }}
	req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))
	res, err := c.Do(req)
	if err != nil {
		panic(err)
	}
	io.Copy(io.Discard, res.Body)
	res.Body.Close()
	return reused, res.Close
}

func run(label string, c *http.Client, url string, between time.Duration, closeAfter bool) {
	var parts []string
	for i := 0; i < 3; i++ {
		if i > 0 {
			time.Sleep(between)
		}
		reused, _ := fetch(c, url, closeAfter)
		parts = append(parts, fmt.Sprint(reused))
	}
	fmt.Printf("%-52s reused per request: %s\n", label, strings.Join(parts, ", "))
	c.CloseIdleConnections()
}

func main() {
	plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }))
	defer plain.Close()

	impatient := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }))
	impatient.Config.IdleTimeout = 50 * time.Millisecond // the server hangs up on idle connections quickly
	impatient.Start()
	defer impatient.Close()

	noKeepAlives := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }))
	noKeepAlives.Config.SetKeepAlivesEnabled(false)
	defer noKeepAlives.Close()

	client := func(t *http.Transport) *http.Client { return &http.Client{Transport: t} }

	run("defaults, requests back to back", client(&http.Transport{}), plain.URL, 0, false)
	run("DisableKeepAlives on the client", client(&http.Transport{DisableKeepAlives: true}), plain.URL, 0, false)
	run("request sets Connection: close", client(&http.Transport{}), plain.URL, 0, true)
	run("client IdleConnTimeout 50ms, 150ms pause", client(&http.Transport{IdleConnTimeout: 50 * time.Millisecond}), plain.URL, 150*time.Millisecond, false)
	run("server IdleTimeout 50ms, 150ms pause", client(&http.Transport{}), impatient.URL, 150*time.Millisecond, false)
	run("server has keep-alives disabled", client(&http.Transport{}), noKeepAlives.URL, 0, false)

	_, said := fetch(client(&http.Transport{}), noKeepAlives.URL, false)
	fmt.Println("response.Close set when the server refuses keep-alive:", said)
}
Text
defaults, requests back to back                      reused per request: false, true, true
DisableKeepAlives on the client                      reused per request: false, false, false
request sets Connection: close                       reused per request: false, false, false
client IdleConnTimeout 50ms, 150ms pause             reused per request: false, false, false
server IdleTimeout 50ms, 150ms pause                 reused per request: false, false, false
server has keep-alives disabled                      reused per request: false, false, false
response.Close set when the server refuses keep-alive: true

The first row is the baseline: the first request has to dial, so Reused is false, and the next two are served from the pool. Every other row breaks that in a different way, and in each, the client never reports an error. It just quietly dials again:

  • DisableKeepAlives on the transport, or req.Close = true on a request (which sends Connection: close), makes each exchange use its own connection. Both are sometimes right, for example when talking to a flaky appliance that cannot handle persistent connections. They are almost never right as a performance default.
  • The client's IdleConnTimeout closes a pooled connection that has not been used for that long. DefaultTransport uses 90 seconds. With a 50 ms limit and a 150 ms pause, every request found the pool empty.
  • The server's idle timeout does the same from the other end. http.Server.IdleTimeout closes keep-alive connections that have been quiet for that long; the client noticed the closed connection and dialed again, with no error.
  • A server with keep-alives off answers with Connection: close, which the client reports through res.Close, and reuse never happens.

The race you cannot test

That fourth and fifth row hide the one failure that does produce errors. If the server closes an idle connection at the same instant the client picks it from the pool, the client writes a request onto a connection that is already dead and reads EOF or connection reset by peer. Go's transport protects you in the common case: it retries the request on a fresh connection if the failed attempt was on a reused connection and the request is idempotent (or has no body written yet). Non-idempotent requests that had already started sending a body surface the error to your code.

The practical rule that avoids it is about ordering. Your client should give up on an idle connection before the other side does. If the server's idle timeout is 60 seconds, a client at the default 90 seconds will sometimes reuse a connection the server is closing. Set the client's IdleConnTimeout a few seconds below the lowest idle timeout in the path. That includes load balancers, which are the usual surprise: AWS's classic ELB and ALB default to 60 seconds, many proxies to 60 or 75, and some cloud NAT gateways drop idle flows after 350 seconds without sending a reset. A client with a 30 second idle timeout fits under most of them.

On the server side the matching setting is IdleTimeout, which defaults to ReadTimeout when unset, and to no limit when both are zero, as covered in server timeouts. A server without an idle timeout accumulates one goroutine and one socket per client that ever connected and went quiet.

Reading the pool from the outside

Two other signals help when you cannot add a trace. On the server, count connections: http.Server.ConnState fires StateNew for each accepted socket, and a counter that rises with every request is a client that is not reusing. On the client host, ss -tn state established '( dport = :443 )' | wc -l (or netstat on macOS) shows live sockets to an upstream, and a large number of sockets in TIME_WAIT after a load test is the classic mark of a client that closes after every request, because the side that closes first carries that state. Neither tells you why, which is the job of the httptrace output above; together they let you confirm the fix with the same measurement that found the problem.

What a handshake costs

The point of all this is latency. A new connection needs a TCP three-way handshake, one round trip. For HTTPS, TLS 1.3 adds another round trip (TLS 1.2 adds two), plus the CPU cost of the key exchange and certificate verification. Against a service in the same availability zone that is under a millisecond each; against one on another continent it is 100 to 300 ms each, paid in front of every call that does not reuse a connection. A quick way to see it for your own setup is to compare the time of the first and second request in a trace with ConnectStart, ConnectDone, TLSHandshakeStart and TLSHandshakeDone hooks added to the ClientTrace above.

For HTTP/2 the question changes: one connection carries all requests, and reuse is the norm. The pitfalls move to how many streams run on it and what happens when it breaks, which the HTTP/2 article covers.

A check for your own code

Put the assertion into a test for any code that makes repeated calls to the same host. Wrap the transport or use a trace in the test, make five calls, and fail if more than one connection was opened. It catches the three regressions that happen in real projects: a refactor that drops defer res.Body.Close(), a helper that creates a new http.Client per call, and a copied Transport literal with DisableKeepAlives: true left over from debugging. The httptest server's ConnState hook, used in the previous articles, counts connections from the other end without any tracing. The httptrace package documentation lists every hook, and RFC 9112 section 9 is the specification for persistent connections.

Taggednet http
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.