Saving a round trip depends on the protocol and early-data mode. In TLS 1.3, an ordinary full handshake and a resumed handshake without early data normally both take one TLS round trip. Resumption can still reduce certificate transmission and authentication work. The title is not a promise that enabling tickets makes every HTTPS request start one RTT sooner.

New connection, fresh traffic keys

TLS resumption establishes a new connection using a PSK derived from an earlier handshake. A NewSessionTicket supplies an opaque identity associated with that state; it is not permission to reuse old traffic keys unchanged. The new handshake derives traffic keys for its own context. Servers may protect ticket state cryptographically or keep state behind an opaque identifier; applications should not parse tickets as portable credentials.

When the server accepts PSK authentication, it omits its Certificate and CertificateVerify messages. Resumption can include a fresh ephemeral Diffie-Hellman exchange for forward secrecy. TLS 1.3 also permits PSK-only operation, which lacks that protection. Do not infer either the selected mode or a latency saving from the existence of a ticket.

Optional 0-RTT lets eligible application data accompany ClientHello. That is separate from ordinary resumption. Early data is not forward secret and can be replayed across connections; even completing one handshake does not undo a replay elsewhere. HTTP deployments must decide which operations tolerate replay and handle rejection appropriately, including 425 Too Early where specified.

When it breaks

A client may never cache tickets, may close before processing a post-handshake ticket, or may lose cached state between requests. A server may decline an expired or unrecognized ticket after a restart or key rotation. A full handshake after an attempted resume is not automatically a TLS outage; measure the resume rate separately from connection success.

HTTP connection reuse can hide the distinction entirely. Repeating requests on one established socket performs no new handshake. To test resumption, deliberately create a new TCP connection and inspect both peers’ negotiated state. Request duration alone mixes scheduling, transport setup, server work, and cache effects.

Ticket and cache state are sensitive. Limit their scope and lifetime, coordinate server key rotation, and revisit cached authentication when policy changes. In Go, VerifyPeerCertificate is not called on resumed connections; VerifyConnection runs on all connections. Custom checks that must apply to resumptions belong on the latter path. This does not justify disabling normal certificate verification.

Check it quickly

Save this as resumption_probe.go and run go run resumption_probe.go with Go 1.23 or newer. The local HTTPS fixture and client both require TLS 1.3. Its certificate is trusted only inside the fixture. HTTP/1.1 keep-alive reuse is disabled, while two separate transports share the same four-entry TLS session cache. A third transport deliberately has no cache.

package main

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

type connectionKey struct{}

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

func main() {
	var serial, verified atomic.Int64
	server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Context().Value(connectionKey{}).(int64)
		w.Header().Set("X-Connection-ID", strconv.FormatInt(id, 10))
		w.Header().Set("X-Resumed", strconv.FormatBool(r.TLS.DidResume))
		fmt.Fprintln(w, "ok")
	}))
	server.Config.ConnContext = func(ctx context.Context, connection net.Conn) context.Context {
		return context.WithValue(ctx, connectionKey{}, serial.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")
	base.VerifyConnection = func(state tls.ConnectionState) error {
		if len(state.VerifiedChains) == 0 {
			return fmt.Errorf("verified certificate chain required")
		}
		verified.Add(1)
		return nil
	}
	request := func(cache tls.ClientSessionCache) (int64, bool) {
		config := base.Clone()
		config.ClientSessionCache = cache
		transport := &http.Transport{
			TLSClientConfig:   config,
			DisableKeepAlives: true,
		}
		defer transport.CloseIdleConnections()
		client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
		response, err := client.Get(server.URL)
		check(err == nil, fmt.Sprint(err))
		defer response.Body.Close()
		body, err := io.ReadAll(response.Body)
		check(err == nil, fmt.Sprint(err))
		check(response.StatusCode == 200 && string(body) == "ok\n", "unexpected HTTP reply")
		check(response.ProtoMajor == 1 && response.ProtoMinor == 1, "HTTP/1.1 required")
		check(response.TLS.HandshakeComplete && response.TLS.Version == tls.VersionTLS13, "TLS 1.3 required")
		id, err := strconv.ParseInt(response.Header.Get("X-Connection-ID"), 10, 64)
		check(err == nil, fmt.Sprint(err))
		resumed := response.TLS.DidResume
		check(response.Header.Get("X-Resumed") == strconv.FormatBool(resumed), "peers disagree")
		return id, resumed
	}

	cache := tls.NewLRUClientSessionCache(4)
	firstID, first := request(cache)
	secondID, second := request(cache)
	thirdID, third := request(nil)
	check(firstID == 1 && secondID == 2 && thirdID == 3, "three new TCP connections required")
	check(!first && second && !third, "expected full, resumed, full")
	check(verified.Load() == 3, "VerifyConnection must run for all three handshakes")
	fmt.Printf("new TCP connections: %d %d %d; DidResume: %t %t %t\n",
		firstID, secondID, thirdID, first, second, third)
	fmt.Println("both peers agree; VerifyConnection ran three times; no early data was sent")
	fmt.Println(runtime.Version())
}

Reading each response to completion lets this fixture’s post-handshake ticket be processed before the next connection. Each accepted TCP connection gets an independent server-side number. The expected sequence is connections 1, 2, 3 with DidResume false, true, false. The response also carries the server’s own resume flag, which must agree with the client’s value. VerifyConnection runs three times, including the resumed handshake, while the standard certificate checks remain enabled.

All assertions passed on macOS with Go 1.27.0. The resumed connection’s verified-chain state does not mean a new certificate message was transmitted; it is associated with the authenticated session being resumed. Session state stays in the in-memory cache; no ticket, private key, or traffic secret is printed or written to disk.

This experiment proves acceptance of a cached session on a new transport and full negotiation without the cache. It does not send early data, count packets, benchmark handshake CPU, or validate ticket sharing across a load-balanced fleet. Test those separately before predicting a production speedup. A faster local second request alone would prove none of them.

Further reading

Sources