HTTPS latency combines several stages, and a warm request may avoid some without making cryptography faster. DNS, connection acquisition, TCP setup, TLS authentication, server work, and response transfer have different clocks. First determine which stages actually ran. Request duration alone cannot distinguish an existing connection from a new connection that resumed a TLS session.

Messages travel in flights

A TLS handshake contains several messages, but each message does not require its own round trip. Messages can travel together in a flight. A usual TLS 1.3 full handshake needs one TLS round trip before normal client application data; HelloRetryRequest adds another. TCP establishment and the later application request/response remain separate costs.

Ordinary TLS 1.3 resumption without early data also normally takes one TLS round trip. It can reduce certificate transmission and authentication work, but may still perform fresh ephemeral key exchange. Resumption derives new traffic keys rather than reusing old encryption keys unchanged. Optional 0-RTT is a separate facility with replay-related security limits, not a synonym for a fast repeat request.

HTTP connection reuse avoids establishing another connection entirely, so that request performs no new TLS handshake. HTTP/2 multiplexing can similarly share one established connection among streams. In contrast, closing an idle connection and opening another one still requires transport setup, even if a cached ticket lets TLS resume.

When it breaks

Creating a new transport for every normal request discards the connection pool. Leaving response bodies unread or unclosed can also prevent reuse. Idle timeouts, endpoint changes, or concurrent demand can create additional connections. Classify requests by observed reuse and handshake events before attributing a latency change to certificate algorithms.

A full handshake after attempted resumption is not necessarily an outage. Tickets can expire or be declined, and a client may lack usable cached state. Preserve normal certificate verification and measure the resulting behavior. Reducing authentication checks to win a benchmark would change the security contract, not simply improve the same operation.

Timing names need definitions. Time to first response byte includes more than TLS, while total request time also includes consuming the response. Trace hooks can run on different goroutines, so collectors need synchronization. Compare representative distributions and connection categories; a few loopback microsecond values cannot predict an Internet speedup.

Check it quickly

Save this as latency_probe.go and run go run latency_probe.go with Go 1.23 or newer. The fixture uses verified TLS 1.3 and HTTP/1.1. A server-side counter identifies accepted TCP connections. The first transport retains both an HTTP connection pool and a TLS session cache; a separate transport deliberately has no session cache.

package main

import (
	"context"
	"crypto/tls"
	"fmt"
	"io"
	"net"
	"net/http"
	"net/http/httptest"
	"net/http/httptrace"
	"runtime"
	"strconv"
	"sync"
	"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 connections atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("X-Connection-ID", fmt.Sprint(r.Context().Value(connectionKey{})))
		w.Header().Set("X-Resumed", strconv.FormatBool(r.TLS.DidResume))
		fmt.Fprint(w, "ok")
	}))
	server.Config.ConnContext = func(ctx context.Context, _ net.Conn) context.Context {
		return context.WithValue(ctx, connectionKey{}, connections.Add(1))
	}
	server.TLS = &tls.Config{MinVersion: tls.VersionTLS13, MaxVersion: tls.VersionTLS13}
	server.StartTLS()
	defer server.Close()
	base := server.Client().Transport.(*http.Transport).TLSClientConfig.Clone()
	base.MinVersion, base.MaxVersion = tls.VersionTLS13, tls.VersionTLS13
	check(!base.InsecureSkipVerify, "certificate verification must stay enabled")
	cached := base.Clone()
	cached.ClientSessionCache = tls.NewLRUClientSessionCache(4)
	transport := &http.Transport{TLSClientConfig: cached}
	defer transport.CloseIdleConnections()
	fresh := &http.Transport{TLSClientConfig: base.Clone()}
	defer fresh.CloseIdleConnections()
	probe := func(label string, transport *http.Transport, wantID int64, wantReuse, wantResume bool) {
		started := time.Now()
		var mu sync.Mutex
		var handshakeStarted time.Time
		var handshakeError error
		var observation struct {
			got, starts, done, first int
			reused                   bool
			tlsTime, ttfb            time.Duration
		}
		trace := &httptrace.ClientTrace{
			GotConn: func(info httptrace.GotConnInfo) {
				mu.Lock()
				defer mu.Unlock()
				observation.got++
				observation.reused = info.Reused
			},
			TLSHandshakeStart: func() {
				mu.Lock()
				defer mu.Unlock()
				handshakeStarted = time.Now()
				observation.starts++
			},
			TLSHandshakeDone: func(_ tls.ConnectionState, err error) {
				mu.Lock()
				defer mu.Unlock()
				observation.done++
				observation.tlsTime = time.Since(handshakeStarted)
				handshakeError = err
			},
			GotFirstResponseByte: func() {
				mu.Lock()
				defer mu.Unlock()
				observation.first++
				observation.ttfb = time.Since(started)
			},
		}
		request, err := http.NewRequest(http.MethodGet, server.URL, nil)
		must(err)
		request = request.WithContext(httptrace.WithClientTrace(request.Context(), trace))
		client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
		response, err := client.Do(request)
		must(err)
		body, err := io.ReadAll(response.Body)
		closeError := response.Body.Close()
		must(err)
		must(closeError)
		total := time.Since(started)
		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 && response.TLS.DidResume == wantResume, "wrong connection or resumption")
		check(response.Header.Get("X-Resumed") == strconv.FormatBool(wantResume), "peers disagree on resumption")
		mu.Lock()
		observed, tlsError := observation, handshakeError
		mu.Unlock()
		must(tlsError)
		wantHandshakes := 1
		if wantReuse {
			wantHandshakes = 0
		}
		check(observed.got == 1 && observed.first == 1 && observed.reused == wantReuse, "wrong request trace")
		check(observed.starts == wantHandshakes && observed.done == wantHandshakes, "wrong handshake count")
		check(total >= observed.ttfb && observed.ttfb > 0, "invalid response timing")
		fmt.Printf("%s: conn=%d reused=%t handshakes=%d connection_resumed=%t tls_us=%d ttfb_us=%d total_us=%d\n",
			label, id, observed.reused, observed.done, response.TLS.DidResume,
			observed.tlsTime.Microseconds(), observed.ttfb.Microseconds(), total.Microseconds())
	}
	probe("cold", transport, 1, false, false)
	probe("reuse", transport, 1, true, false)
	transport.CloseIdleConnections()
	probe("resume", transport, 2, false, true)
	probe("no cache", fresh, 3, false, false)
	check(connections.Load() == 3, "four requests must use three TCP connections")
	fmt.Println("timings are observations, not a speedup or wire-RTT assertion")
	fmt.Println(runtime.Version())
}

The invariant results are:

RequestTCP IDReusedNew handshakesDidResume
cold1false1false
reuse1true0false
resume2false1true
no cache3false1false

The second request keeps connection one. Closing idle connections before the third forces another TCP connection without discarding the TLS cache, so it resumes. The fourth request uses a transport with no cached session and performs a full handshake. Both peers’ resumption flags agree, and four HTTP requests use exactly three accepted TCP connections.

DidResume describes how that connection was established, not whether the current HTTP request triggered resumption. Reusing a connection originally established by resumption can still report true without another handshake. Interpret this connection-level flag alongside the request’s reuse and handshake events, rather than counting every true response as a new resumed connection.

TLS timing spans TLSHandshakeStart through TLSHandshakeDone. TTFB starts before the request and ends when the first response-header byte becomes available; total ends after reading and closing the body. The reused row’s zero TLS duration means no new handshake event was observed. It does not mean an extraordinarily fast handshake or TLS 0-RTT.

All assertions passed on macOS with Go 1.27.0. Bodies are consumed and closed, trace state is mutex-protected, and each request has a two-second timeout. Session state stays in memory; no ticket, private key, or traffic secret is printed. On resumed connections, verified-chain state belongs to the authenticated session, not evidence of a newly transmitted certificate.

The timing numbers intentionally have no speedup assertion and will vary. This local experiment does not count packets or measure wire RTT, DNS, HTTP/2 or HTTP/3, early data, fleet ticket rotation, handshake CPU in isolation, or production latency distributions. Use the trace categories to structure those later measurements without treating this fixture as their result.

Further reading

Sources

  • RFC 8446: TLS 1.3, handshake flights, resumption, and early-data limits, checked 2026-09-09.
  • Go net/http/httptrace, request hooks, handshake events, timing boundaries, and concurrency, checked 2026-09-09.
  • Go net/http, transport reuse and response-body lifecycle, checked 2026-09-09.
  • Go crypto/tls, session caching and negotiated resumption state, checked 2026-09-09.
  • Go net/http/httptest, isolated TLS servers and trusted test clients, checked 2026-09-09.