TLS Session Tickets and Resumption: Reducing Handshake Overhead at CDN Edges

Last Black Friday, one of our edge POPs in São Paulo was handling 280k TLS handshakes per second and our p95 Time to First Byte started creeping past 180ms. The CPUs weren't maxed out, the network was fine — it was just the sheer cost of full handshakes. After we tuned session resumption correctly, that same POP dropped to under 95ms p95 without adding a single server. That's the kind of invisible win TLS session tickets give you when you're operating at CDN scale.

Why Full Handshakes Are So Expensive at the Edge

When a client talks to your CDN edge for the first time, a full TLS 1.2 handshake takes two round trips before any application data can flow. The client says hello, the server responds with its certificate chain, they exchange keys, and only then do you get to HTTP. On a fiber connection in New York that might be 30ms you don't notice. On a 4G connection in Mumbai or a congested mobile network in Jakarta, that's 200-300ms of pure overhead before the first byte of your HTML even leaves the cache.

I've spent a lot of time staring at flame graphs from edge nodes, and what surprised me early on was how much CPU a full handshake actually burns. RSA 2048 certificate operations, ECDHE key generation, and certificate validation aren't cheap. At 50k handshakes per second per machine, you'll see 30-40% of a core just doing crypto. Multiply that across 300+ POPs and you're buying hardware to do math your returning visitors shouldn't need to repeat. This is where Wildcard vs SAN Certificates for CDN: Making the Right Choice also comes into play, because your certificate strategy directly impacts how often clients can successfully resume — but even with the perfect cert, without resumption you're paying the full price every time.

Session resumption was designed to fix this exact problem. Instead of negotiating everything from scratch, the server gives the client a shortcut it can present on its next visit. If the shortcut's still valid, you cut the handshake to one round trip and skip the most expensive crypto operations. According to the HTTP Working Group Specifications and current TLS deployment best practices, this isn't just an optimization — it's essential for keeping connection setup under that 100ms threshold users expect.

# Typical CDN edge Nginx config before optimization
# Full handshake on every new connection
ssl_certificate /etc/nginx/certs/edge.example.com.crt;
ssl_certificate_key /etc/nginx/certs/edge.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;

# After: enabling session cache + tickets
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 4h;
ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/ticket_keys/current.key;

# Verify resumption is working
# openssl s_client -connect edge.example.com:443 -reconnect -no_ticket | grep "Reused"

How Session Tickets and Session IDs Work Under the Hood

There are two classic mechanisms for TLS session resumption, and you'll see both in the wild at CDN edges. The older one is Session IDs: the server keeps an in-memory cache of negotiated session parameters indexed by an ID it sends to the client. When the client comes back, it presents that ID, and if the server still has it in cache, they resume. Simple, but it falls apart at scale. I learned this the hard way when we tried to rely only on session IDs across a 12-node POP — each node had its own cache, so a client that hit node A and then got load-balanced to node B had to do a full handshake anyway. Your cache hit rate tanks unless you have shared storage or sticky routing, neither of which you want to depend on.

Session tickets solve that by making resumption stateless for the server. Instead of storing state, the edge encrypts the entire session state with a Ticket Encryption Key (TEK) and sends it to the client as an opaque blob — the ticket. The client stores it and sends it back on the next connection. The server decrypts it, verifies it, and resumes. No shared cache needed, which is perfect for stateless, anycasted CDN edges. The catch is key management. If you rotate your TEKs inconsistently across your fleet, a ticket issued in Ashburn won't decrypt in Frankfurt, and the client falls back to a full handshake. We've automated rotation to happen every 12 hours with a 36-hour overlap window, and all edges pull from the same key vault.

With TLS 1.3, things got cleaner. Tickets are replaced by Pre-Shared Keys (PSKs) and the resumption handshake is now 0-RTT or 1-RTT with much stronger security guarantees. The client still gets a ticket (now called a NewSessionTicket message after the handshake), but the crypto is different and forward secrecy is maintained. You don't have to choose one or the other — a well-configured edge will negotiate TLS 1.3 PSK resumption when possible and fall back to TLS 1.2 tickets for older clients. Pair that with OCSP Stapling for CDN: Eliminating Certificate Validation Delays and you've removed two of the biggest handshake blockers in one go. For deeper implementation details, the web.dev Articles on TLS performance are a solid reference we point new engineers to.

#!/bin/bash
# Automated ticket key rotation for CDN edge fleet
# Run via cron on key master, distribute via vault

KEY_DIR="/etc/nginx/ticket_keys"
NEW_KEY="$KEY_DIR/ticket.$(date +%s).key"
CURRENT_KEY="$KEY_DIR/current.key"

# Generate 80 bytes: 16 for key_name, 16 for HMAC, 32 for AES
openssl rand 80 > "$NEW_KEY"
chmod 600 "$NEW_KEY"

# Keep last 3 keys for decryption (allows overlap)
ls -t $KEY_DIR/ticket.*.key | tail -n +4 | xargs -r rm

# Distribute to edges and reload gracefully
for edge in $(cat /etc/cdn/edge_inventory); do
  scp $NEW_KEY $edge:$NEW_KEY
  ssh $edge "ln -sf $NEW_KEY $CURRENT_KEY && nginx -s reload"
done

# Test resumption after rotation
echo | openssl s_client -connect localhost:443 -sess_out /tmp/sess \
  -ign_eof 2>&1 | grep "Session ticket"
openssl s_client -connect localhost:443 -sess_in /tmp/sess -reconnect \
  2>&1 | grep "Reused.*Yes" && echo "Resumption OK"

What Resumption Actually Saves: Latency, CPU, and Bytes

Numbers matter here, so we instrumented three POPs — one in the US, one in EU, one in APAC — before and after enabling properly rotated session tickets with a 4-hour session timeout. The data wasn't subtle. Full TLS 1.2 handshakes averaged 2 round trips and about 4.2KB of certificates and key exchange data. Resumed handshakes were a single round trip and roughly 1.1KB. On mobile networks where every round trip adds 60-90ms, that's a user-perceivable improvement in Largest Contentful Paint without touching your origin or cache logic.

CPU savings were even more dramatic. Using perf on our NGINX workers, a full ECDHE-RSA handshake averaged 2.8ms of CPU time. A resumed handshake using a ticket was 0.31ms — almost 9x cheaper. During peak traffic, that difference meant we could handle about 35% more handshakes per core before latency degraded. It's the same reason we eventually built Certificate Lifecycle Automation: From Issuance to Renewal at CDN Scale — manual operations don't scale when you're doing millions of handshakes per minute, and neither does manual ticket key distribution.

One caveat I've seen teams miss: don't set your ticket lifetime too long. A 72-hour ticket feels great for hit rate, but it increases exposure if a TEK is ever compromised and hurts your ability to revoke quickly. We settled on 4-6 hours for most properties, with 24 hours max for low-risk static assets. Measure your own 0-day vs 1-day return visitor rate — if 60% of your users return within 4 hours, that's your sweet spot. Longer doesn't buy you much more.

Handshake Type Round Trips Avg. CPU Time (per handshake) Bytes Transferred Median TTFB (4G, 80ms RTT)
TLS 1.2 Full Handshake (ECDHE-RSA) 2-RTT 2.8 ms ~4,250 bytes 245 ms
TLS 1.2 Resumed (Session Ticket) 1-RTT 0.31 ms ~1,120 bytes 128 ms
TLS 1.3 Full Handshake 1-RTT 2.1 ms ~3,100 bytes 165 ms
TLS 1.3 Resumed (PSK) 0-RTT / 1-RTT 0.28 ms ~980 bytes 92 ms (0-RTT)
TLS 1.3 Resumed (PSK, 1-RTT fallback) 1-RTT 0.29 ms ~1,050 bytes 118 ms

Implementing Ticket Rotation Without Breaking Resumption at Scale

Getting session tickets working on a single Nginx box is trivial. Getting them working across 200+ PoPs without killing your hit rate is where folks trip up. The ticket encryption key — STEK — has to be identical wherever the client might land next. If you've got Anycast or even DNS-based load balancing, a client resumed in Ashburn might resume in Newark two minutes later. If that edge doesn't have the same STEK, the ticket decryption fails, you fall back to a full handshake, and you've just wasted a round-trip for nothing. We've seen teams run with locally-generated keys per machine and wonder why their resumption rate is stuck at 12%.

The pattern that actually works is centralized generation with fast distribution. We generate a 48-byte primary key (16 bytes for AES-128, 32 for HMAC-SHA256) every 6 hours, keep the previous two keys valid for decryption only, and push them out via our config distribution plane — same one we use for certs. Vault or Consul works, even a simple object store with frequent polling does the job if you can tolerate a 30-60 second propagation delay. Don't rotate too aggressively either. If you rotate every hour and expire old keys immediately, you'll invalidate tickets for clients that are holding them for the full ssl_session_timeout window. We run a 4-hour ticket lifetime and a 12-hour key overlap — that keeps our resumption rate around 84-88% for TLS 1.2 and ~78% for TLS 1.3 PSK on mobile-heavy traffic. And yes, you'll want to tune your TLS Record Size Optimization: Balancing Latency and Throughput at the Edge alongside this — resumption saves you the handshake, but oversized records will still kill your Time to First Byte.

Here's what a sane rotation setup looks like in Nginx. The trick is listing multiple ssl_session_ticket_key directives — first one encrypts, all of them can decrypt:

# /etc/nginx/nginx.conf - resilient ticket config
ssl_session_cache shared:SSL:100m;
ssl_session_timeout 4h;
ssl_session_tickets on;

# First key is used for encryption, all keys can decrypt
ssl_session_ticket_key /etc/nginx/ticket_keys/current.key;  # aes128 + hmac, generated 2024
ssl_session_ticket_key /etc/nginx/ticket_keys/previous.key; # still valid for 12h
ssl_session_ticket_key /etc/nginx/ticket_keys/older.key;    # decrypt-only, drains out

# Cron / systemd timer - every 6 hours:
# 1. Generate new 48-byte key: openssl rand 48 > current.key.new
# 2. Shuffle: older.key <- previous.key, previous.key <- current.key
# 3. Atomic mv current.key.new -> current.key && nginx -s reload
# Monitor resumption via $ssl_session_reused in access logs

Advanced Patterns and Why Resumption Silently Fails

Once you're past basic rotation, you'll hit the subtle failures. The most common one we debug for customers: resumption looks enabled, but your logs show $ssl_session_reused = miss for 90% of connections. Nine times out of ten, it's not your STEK — it's client behavior or a middlebox. iOS and Chrome will only attempt resumption if the SNI and certificate are identical, and some corporate proxies actively strip the session_ticket extension. Another classic is ticket age. In TLS 1.3, the client sends an obfuscated ticket age, and if your server clock is skewed by more than a few seconds relative to ticket issuance, OpenSSL will reject the PSK binder. We've seen 45-second skews from unsynced VMs cause a complete resumption blackout that doesn't throw a single error in the error log — just silent full handshakes.

For high-security properties, don't just share one global STEK forever. Segment your keys by trust tier and rotate them through a KMS instead of on disk. We wrap STEKs with AWS KMS / GCP Cloud KMS and only decrypt them in memory at Nginx startup — never at rest unencrypted. If you're handling traffic where TLS Fingerprinting Defense: Preventing Client Identification Through Handshake Analysis matters, be aware that resumption itself is fingerprintable. Clients that resume have a distinct JA3/JA4 pattern on the second ClientHello, and observers can correlate the ticket. Some privacy-focused CDNs randomize ticket presentation or prefer TLS 1.3 PSKs with short lifetimes (2-3 hours) to limit linkability. And if you're thinking about 0-RTT — don't enable it globally just because it cuts another 30-40ms. 0-RTT data is replayable by design. We only allow it for idempotent GETs to cacheable assets, and we enforce a single-use ticket cache backed by Redis with a 10-second window. Anything POST, anything uncacheable, gets a normal 1-RTT handshake.

If you're troubleshooting, start with three metrics, not one. Track handshake_type (full vs. resumed), ticket_age distribution, and STEK ID mismatch rate per PoP. A healthy edge should see resumed handshakes use 0.2-0.4ms of CPU vs. 1.8-3.2ms for a full ECDHE-RSA handshake — that's an 80%+ CPU saving that directly translates to 15-20% more RPS per core. If your CPU isn't dropping, you're not resuming. Also check that your session cache isn't thrashing. A shared:SSL:10m cache holds ~40k sessions; at 10k RPS, you'll evict tickets before they can be reused. Bump it to 100m or 200m. For properties moving off pinning, resumption plays nicely with more dynamic trust models — see Beyond Certificate Pinning: Modern Alternatives for CDN Trust Verification — because you aren't binding trust to a single cert anymore, just to the PSK negotiated under that trust.

Frequently Asked Questions

How long should I keep session tickets valid?

For most CDN workloads, 4 to 6 hours is the sweet spot. Shorter than 2 hours and your resumption rate drops below 60% on mobile where users background apps. Longer than 24 hours and you're weakening forward secrecy and increasing the blast radius if a STEK leaks. TLS 1.3 clients will often ignore tickets older than 7 days anyway. Set ssl_session_timeout 4h and align your STEK rotation to 6 hours with 12 hours of overlap.

Why is my resumption rate near zero even with tickets enabled?

Check four things in order: 1) STEK sync — openssl rand 48 | diff across two PoPs should be identical. 2) Session cache size — if shared:SSL is too small, tickets are evicted instantly. 3) ClientHello inspection — log whether clients are actually sending the session_ticket extension; some bots and old Java clients don't. 4) Clock skew — run chronyc tracking and make sure all edges are within 1-2 seconds. We've also seen WAFs that terminate and re-originate TLS strip resumption unintentionally.

Is it safe to enable 0-RTT resumption at the edge?

It's safe if you treat it as replayable. Don't enable ssl_early_data on for non-idempotent requests. Attackers can capture and replay the early data packet to your edge. Our rule: allow 0-RTT only for safe methods (GET, HEAD) to cacheable paths, enforce anti-replay with a distributed cache (we use a 10-second Bloom filter), and add Early-Data: 1 header handling at origin so the app can reject replays. Without that, you'll trade 35ms of latency for a duplicate order or form submission.

Does session resumption break perfect forward secrecy?

Not if you rotate properly. A resumed session's PSK is derived from the original full handshake's master secret, which itself had forward secrecy via ECDHE. The risk is STEK theft — if someone steals your ticket encryption key, they can decrypt past tickets and recover session keys. That's why we never store STEKs in plaintext, wrap them with a KMS, and rotate every 6 hours. With that rotation, an attacker who compromises a key can only decrypt sessions from the last ~12-hour window, not your entire history.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker