Optimizing Go TLS Handshake Latency for HTTP/2 Servers
What the handshake actually costs
A full TLS 1.2 handshake is two network round trips plus asymmetric crypto. On a 100ms RTT link that is 200ms before the first byte, and the crypto is real server CPU – each ECDHE key generation and certificate signature is meaningful work when you are doing thousands of handshakes a second. TLS 1.3 cut the full handshake to one round trip and dropped the RSA key exchange entirely. Those are the two big levers, and then session resumption removes most of the remaining work.
Protocol version and resumption
- TLS 1.3 (default in Go since 1.13): a full handshake is one round trip. Set
MinVersion: tls.VersionTLS12at least; TLS 1.3 only if your clients allow it. - Session resumption: a resumed TLS 1.2 handshake drops from two round trips to one; a resumed TLS 1.3 handshake skips certificate-chain validation and key exchange, so it is dramatically cheaper on CPU. Go enables session tickets by default, and the ticket is stateless – the server encrypts the session state into the ticket. That is why you can scale resumed handshakes horizontally: no shared session cache, just a shared ticket key.
One correction to the common advice: do not set CipherSuites, and ignore PreferServerCipherSuites entirely – it has been a no-op since Go 1.14. Go’s internal cipher selection already picks AES-GCM when AES hardware acceleration is present and ChaCha20-Poly1305 otherwise. Hand-tuning cipher suites is how you accidentally ship a slower, less secure configuration.
A config that matches what we actually run
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main
import (
"crypto/tls"
"log"
"net/http"
"os"
)
func tlsConfig(certPEM, keyPEM []byte) *tls.Config {
pair, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
log.Fatal(err)
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
CurvePreferences: []tls.CurveID{tls.X25519},
NextProtos: []string{"h2", "http/1.1"},
// Certificates are parsed once at startup; never touch the disk
// during a handshake.
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
return &pair, nil
},
}
}
func main() {
cert, err := os.ReadFile("cert.pem")
if err != nil {
log.Fatal(err)
}
key, err := os.ReadFile("key.pem")
if err != nil {
log.Fatal(err)
}
srv := &http.Server{Addr: ":443", TLSConfig: tlsConfig(cert, key)}
if err := srv.ListenAndServeTLS("", ""); err != nil {
log.Fatal(err)
}
}
Three details in this config are load-bearing:
NextProtos: []string{"h2", "http/1.1"}– ALPN negotiates HTTP/2 during the TLS handshake itself. Without it, upgrading to HTTP/2 costs an extra round trip. Same handshake, HTTP/2 from byte one, one fewer round trip on every connection.GetCertificateloads from memory. Reading the certificate file on every handshake turns a CPU problem into an I/O problem. Parse once at startup, return a cached pointer.X25519only. It is Go’s default curve and the right choice; restricting the preference list removes negotiation noise.
Rotating session ticket keys
Go’s session ticket is encrypted with a single 32-byte key on the config. If you run multiple instances, they must share the key or resumption breaks between nodes. Rotate it on a schedule – once a day is fine – and rotate safely:
1
2
3
4
5
func rotateTicketKeys(cfg *tls.Config, freshKey [32]byte) {
// The first key encrypts new tickets; every key in the set is tried for
// decryption, so in-flight clients resume instead of full-handshaking.
cfg.SetSessionTicketKeys([][32]byte{freshKey, cfg.SessionTicketKey})
}
Replace keys abruptly and every active client pays a full handshake at exactly the moment you rotated.
Measuring handshakes
Guessing at resumption rates is how you end up tuning the wrong thing. httptrace gives you the ground truth from the client side:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func measureHandshake(client *http.Client, url string) {
trace := &httptrace.ClientTrace{
TLSHandshakeDone: func(state tls.ConnectionState, err error) {
if err != nil {
return
}
log.Printf("tls: resumed=%v proto=%s version=%x",
state.DidResume, state.NegotiatedProtocol, state.Version)
},
}
req, _ := http.NewRequestWithContext(
httptrace.WithClientTrace(context.Background(), trace),
http.MethodGet, url, nil,
)
_, _ = client.Do(req)
}
The numbers from production
On an API serving clients at roughly 150ms RTT, forcing TLS 1.3 plus ALPN cut P99 connect time from about 260ms to about 120ms – one round trip instead of two. After pushing clients toward resumption, handshake throughput on the 8-core edge improved roughly 3.5x because resumed handshakes skip the certificate verification and key exchange entirely. Resumption is the CPU win; TLS 1.3 is the latency win; do both.
- Measure with
httptraceand watch theDidResumeratio. Below 50% resumption is a ticket configuration problem, not a network problem. - Rotate STEKs on a schedule and keep the previous key in the set during rotation.
- Cache
GetCertificateresults in memory; parse certificates once at startup. - Share one
*tls.Configacross all servers in the process; the session ticket key lives on the config. - Let Go pick cipher suites. Tuning cipher suites in 2023 is cargo culting with worse security.