HTTP persistence allows multiple exchanges to share a connection, so repeated requests may avoid TCP setup and TLS negotiation. It is a protocol capability, not a promise that a particular socket stays open. Whether another request actually reuses it also depends on complete message framing, client pool policy, server behavior, and the connection’s current condition.

Default does not mean immortal

HTTP/1.1 permits persistence by default. A client does not normally need to send Connection: keep-alive to enable it. Connection: close instead announces that the connection will not continue after the current exchange. HTTP/1.0 used different persistence rules; do not copy that negotiation model into every modern request.

Reuse needs an unambiguous message boundary. An accurate Content-Length, chunked framing, or a no-body response rule can provide it. A response delimited by closing the connection cannot leave that connection reusable. The receiving side must finish consuming the message before interpreting subsequent bytes; simply seeing HTTP 200 does not establish that the response body is complete.

Reusing an established HTTPS connection performs no new TLS handshake. Opening a new connection with a cached TLS session is different: that connection still handshakes. TCP keepalive is different again; its idle probes do not renew HTTP message boundaries or override an application’s decision to close. HTTP/2 also has connection reuse, but not HTTP/1.x’s Connection header semantics.

Budget the pool and the operation separately

Idle sockets retain descriptors, buffers, and implementation state. In Go, MaxIdleConnsPerHost controls retained idle connections; MaxConnsPerHost limits dialing, active, and idle connections together. The former is not a total concurrency cap. A shared Transport owns the reusable pool, so constructing unrelated transports for successive calls discards opportunities for reuse.

IdleConnTimeout limits an idle connection’s lifetime; Client.Timeout limits a request, including connection acquisition and response-body reading. Neither name means the other. A reverse proxy also has separate downstream and upstream connections. Reuse and timeout observations on the browser-facing hop do not automatically describe its origin-facing pool.

When it breaks

A server can close while a client is about to reuse an idle connection. Such races require handling, but a reset does not prove the server performed no work. Retrying a non-idempotent operation without an application-level contract can duplicate effects. Increasing idle retention cannot remove this uncertainty.

Unconsumed bodies can reserve resources, delay reuse, or force closure. Complete or explicitly close each response according to the client’s documented behavior. Libraries may drain some abandoned bodies themselves, with implementation-specific limits; do not assume every early close immediately frees a reusable socket. Conversely, one failed reuse does not justify disabling persistence everywhere.

Check it quickly

Save this as persistence_probe.go and run go run persistence_probe.go with Go 1.23 or newer. The local fixture serves HTTP/1.1 over verified TLS 1.3, assigns identifiers to accepted TCP connections, and returns two-byte, length-delimited bodies. It changes neither public services nor the system trust store. One transport permits reuse; its separate clone disables it.

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"net/http/httptrace"
	"runtime"
	"strconv"
	"sync/atomic"
	"time"
)

type connectionKey struct{}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func main() {
	var accepted, handled atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connectionKey{})))
		w.Header().Set("X-Request-Connection", r.Header.Get("Connection"))
		w.Header().Set("Content-Length", "2")
		if r.URL.Path == "/close" {
			w.Header().Set("Connection", "close")
		}
		fmt.Fprint(w, "ok")
	}))
	server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
		return context.WithValue(ctx, connectionKey{}, accepted.Add(1))
	}
	server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
	server.StartTLS()
	defer server.Close()
	config := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
	check(!config.InsecureSkipVerify, "certificate verification must stay enabled")
	transport := &http.Transport{
		TLSClientConfig: config, MaxConnsPerHost: 1, IdleConnTimeout: 10 * time.Second,
	}
	defer transport.CloseIdleConnections()
	noReuse := transport.Clone()
	noReuse.DisableKeepAlives = true
	defer noReuse.CloseIdleConnections()
	probe := func(label, route string, tr *http.Transport, wantID int64, wantReuse bool) {
		var got atomic.Int64
		var reused atomic.Bool
		trace := &httptrace.ClientTrace{GotConn: func(info httptrace.GotConnInfo) {
			got.Add(1)
			reused.Store(info.Reused)
		}}
		request, err := http.NewRequest(http.MethodGet, server.URL+route, nil)
		must(err)
		request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
		client := &http.Client{Transport: tr, Timeout: 2 * time.Second}
		response, err := client.Do(request)
		must(err)
		body, readError := io.ReadAll(response.Body)
		closeError := response.Body.Close()
		must(readError)
		must(closeError)
		check(response.StatusCode == 200 && string(body) == "ok", "wrong response")
		check(response.Proto == "HTTP/1.1", "fixture requires HTTP/1.1")
		check(response.TLS.Version == tls.VersionTLS13 && len(response.TLS.VerifiedChains) > 0, "verified TLS 1.3 required")
		id, err := strconv.ParseInt(response.Header.Get("X-Connection-ID"), 10, 64)
		must(err)
		check(id == wantID && got.Load() == 1 && reused.Load() == wantReuse, "wrong connection reuse")
		check(response.Close == (route == "/close" || tr.DisableKeepAlives), "wrong close indication")
		wantHeader := ""
		if tr.DisableKeepAlives {
			wantHeader = "close"
		}
		check(response.Header.Get("X-Request-Connection") == wantHeader, "unexpected request Connection header")
		fmt.Printf("%s: conn=%d reused=%t closing=%t\n", label, id, reused.Load(), response.Close)
	}
	probe("cold", "/ok", transport, 1, false)
	probe("reuse", "/ok", transport, 1, true)
	probe("server close", "/close", transport, 1, true)
	probe("after server close", "/ok", transport, 2, false)
	transport.CloseIdleConnections()
	probe("after idle retirement", "/ok", transport, 3, false)
	probe("reuse disabled one", "/ok", noReuse, 4, false)
	probe("reuse disabled two", "/ok", noReuse, 5, false)
	check(accepted.Load() == 5 && handled.Load() == 7, "seven requests must use five TCP connections")
	fmt.Println(runtime.Version())
}

The first three requests use connection one, including the response announcing server closure. The next request therefore uses connection two. Explicitly retiring idle connections then makes the following request use connection three. The disabled-reuse transport uses connections four and five for its two requests. Seven requests reach five accepted TCP connections.

GotConn confirms the expected reuse flag for every request. The server echoes the received Connection field: ordinary requests have no such field, yet still reuse the socket; disabled-reuse requests carry close. This tests actual connections, protocol selection, certificate validation, body completion, and closure indications, not just a configuration object’s values.

All assertions passed on macOS with Go 1.27.0. Every body is read and closed, each request has a two-second timeout, and both transports are cleaned up. IdleConnTimeout is configured, but expiration is not tested: explicit retirement makes that branch deterministic. The fixture does not measure speedup, TLS resumption, TCP keepalive, pipelining, production resource limits, or proxy behavior. Apply those conclusions only after separate measurements.

Further reading

Sources

  • RFC 9112: HTTP/1.1, authoritative persistence, message-boundary, closure, and retry rules, checked 2026-09-09.
  • MDN: Connection management in HTTP/1.x, hop-by-hop ownership and distinctions between persistence and pipelining, checked 2026-09-09.
  • Go net/http, transport pooling, connection limits, response lifecycle, and timeout semantics, checked 2026-09-09.