TLS Session Resumption, and the Handshake That Never Got Faster

Resumption was enabled everywhere and the repeat handshakes were still full handshakes. Session tickets need a shared key across every backend, and behind a…

Share
TLS Session Resumption, and the Handshake That Never Got Faster. Abstract deep dive illustration in orange and dark grey on debugly.dev

The performance plan said: enable TLS session resumption and cut a round trip plus the asymmetric crypto from every repeat connection. It was enabled, the metrics were checked, and repeat connections were still doing full handshakes almost every time. The feature was on and working, and it was missing, which is the interesting combination.

Resumption is a cache, and like every cache its hit rate is the whole story. The configuration toggles whether the cache exists. The architecture decides whether it hits.

This was nginx 1.27 and a Node 22.14 service behind a four node load balancer, with TLS 1.3 session tickets, observed via curl timing and the server's session counters.

How resumption is supposed to work

A full TLS handshake costs round trips and an expensive key agreement. Resumption lets a client that has talked to the server before skip most of that. Two mechanisms exist.

Session IDs. The server keeps a session table and hands the client an ID. On reconnect the client presents the ID and the server looks it up. The table lives in the server's memory, so it only hits when the client returns to the same server.

Session tickets. The server encrypts the session state into a ticket and gives it to the client to hold. On reconnect the client returns the ticket and the server decrypts it. The server keeps no table, which scales, but the ticket is encrypted with a key the server must still possess, and that key must be the same on whichever server the client returns to.

TLS 1.3 standardised on tickets with a couple of refinements, but the dependency is the same: the server that receives the resumed handshake must share the secret that issued the ticket.

Why it misses behind a load balancer

A client that resumes lands on whichever backend the balancer chooses. If the ticket was issued by backend A and the client lands on backend B, B cannot decrypt it, and the handshake falls back to full. With four backends and no shared ticket key, the best case hit rate is one in four, and with consistent hashing or random balancing it hovers there.

This is the default state of most deployments, because nothing errors. A failed resumption is not a failure. It is a silent fallback to a full handshake, which is correct and slow. So the feature degrades to a coin flip and every dashboard stays green.

The session ID variant fails for the same reason with a different mechanism: the session table is per node, so returning to a different node misses.

Making it hit

Share the ticket key. The fix is to distribute the same ticket key to all backends and rotate it on a schedule everywhere at once. Then any backend can decrypt any ticket and the hit rate becomes bounded by ticket lifetime and client behaviour instead of by the node count. Most servers support loading a shared ticket key, and the rotation story matters because a key that is never rotated is a long lived secret, and one that rotates without coordination causes a resumption blip.

Sticky sessions, with caution. Binding a client to a backend makes the local table hit, at the cost of load distribution and of rebalancing storms when a node dies. It is the brute force answer and usually the wrong one, but it is honest about the mechanism.

Terminate TLS once, at the edge. If the edge does TLS and the internal hops are plaintext or a private TLS with long lived connections, the resumption problem shrinks to the edge, which is one place, and its ticket key is one key. This is why edge termination is the common answer, and it converts a distributed cache problem into a single node configuration problem.

Measuring whether it hits

The honest metric is the ratio of resumed to full handshakes, and it must be measured at the server, because the client cannot tell you what the fleet did. Servers expose counters for session cache hits and ticket resumption successes, and a quick check with a repeated curl shows it from the outside:

curl -vso /dev/null https://host/ 2>&1 | grep -i "SSL session"
# repeat and look for 'Reused' versus 'New'

A second connection to the same host that reports a new session is a miss. Repeating it several times samples your hit rate coarsely, which is enough to discover the one in four pattern.

The timing view complements it. Resumed handshakes shave a round trip and the key agreement, so the TLS phase in the curl timing template drops visibly on a hit, per the curl flag that tells you which part was slow. If the TLS phase never drops on repeat connections, resumption is missing in practice whatever the config says.

The 0-RTT caution

TLS 1.3 adds early data, sending request bytes in the first flight on resumption. It is a further win and a further trap, because early data can be replayed by an attacker, so it is only safe for requests that are idempotent by design. Enabling it because resumption is on is how a replayed payment ends up in a postmortem. Treat early data as a separate decision with an allowlist of safe requests.

What I now do

Resumption is a distributed cache keyed by a secret, and its hit rate is an architectural property, not a configuration one. Behind more than one termination point, share the ticket key or terminate once at the edge, and measure the resumed versus full ratio at the server, because a resumption that misses is a full handshake that reported no error.

The cache miss lesson is the general one from cache invalidation and stale data, and the idle connection cousin, where the connection you hoped to keep dies of boredom instead, is ECONNRESET at exactly sixty seconds.


Published on debugly.dev by Rohit Bhadani | Founder of Krova Cloud