QUIC 0-RTT Connection Resumption: Eliminating Handshake Latency at the Edge

· David Park · 10 min read

Connection setup latency is the silent tax on every web interaction. When a user navigates to a new page, their browser must establish a transport connection and negotiate encryption before a single byte of content arrives. On a traditional TCP+TLS 1.2 stack, this costs 3 round trips before data flows. TLS 1.3 reduced that to 2 round trips (1-RTT TLS on top of the TCP handshake). QUIC collapses transport and encryption into a single protocol, achieving 1-RTT for new connections. But the real breakthrough is QUIC 0-RTT: for returning visitors, application data travels in the very first packet, eliminating handshake latency entirely. For CDN edge servers handling millions of reconnecting users, 0-RTT transforms the connection experience. This article covers how 0-RTT works, its security trade-offs, and production deployment strategies for edge infrastructure.

The Connection Latency Problem

Every network connection begins with a handshake. TCP requires a 3-way handshake (SYN, SYN-ACK, ACK) consuming one round trip. TLS adds its own handshake on top: TLS 1.2 needs 2 additional round trips, TLS 1.3 needs 1. The total cost before any application data can flow:

For a user 200ms from the nearest CDN edge (common for users in developing regions connecting to distant PoPs), the difference between 3-RTT and 0-RTT is 600 milliseconds. That is the difference between a page that feels instant and one that feels sluggish. At CDN scale, where edge servers handle connections from every corner of the globe, 0-RTT is not an optimization — it is a fundamental capability for delivering the experience users expect.

Connection Handshake Comparison: TCP+TLS vs QUIC 0-RTT Connection Setup: TCP+TLS 1.3 vs QUIC 0-RTT TCP + TLS 1.3 (2 RTT) Client Server SYN SYN-ACK ACK RTT 1 ClientHello ServerHello+Finished Finished + Data RTT 2 Data flows after 2 RTT QUIC 0-RTT (0 RTT) Client Edge Initial + 0-RTT Data (encrypted with PSK) Data flows immediately! ServerHello+Finished Handshake completes in background while data flows Saves 200-600ms per reconnection
Figure 1: TCP+TLS 1.3 requires 2 round trips before data flows; QUIC 0-RTT sends data in the first packet using a pre-shared key from a previous session

How QUIC 0-RTT Works

QUIC 0-RTT builds on TLS 1.3's session resumption mechanism. When a client completes a regular 1-RTT QUIC handshake, the server sends a NewSessionTicket message containing encrypted session state. The client stores this ticket along with the transport parameters from the connection. On the next connection to the same server, the client uses the session ticket to derive a pre-shared key (PSK), encrypts application data with this PSK, and includes both the ClientHello and the encrypted 0-RTT data in its first flight of packets.

Performance Comparison: Edge vs Origin Processing
Metric Edge Processing Origin Processing Improvement
Latency (p50)8–15 ms120–300 ms15–20x faster
Cache Hit Ratio85–95%N/AReduces origin load
TLS Handshake1–5 ms (nearby PoP)50–150 ms10–30x faster
Bandwidth Cost$0.01–0.04/GB$0.05–0.12/GB3–5x cheaper
Availability99.99% (multi-PoP)99.9% (single DC)10x fewer outages

The server receives the initial packet, decrypts the 0-RTT data using the PSK derived from the session ticket, and can begin processing the request immediately — before the handshake completes. The handshake finishes in the background, upgrading the connection to full 1-RTT keys with forward secrecy. The 0-RTT data itself does not have forward secrecy (it is encrypted with keys derived from a previous session's state), but subsequent data on the connection does.

Session Ticket Architecture

The session ticket is the foundation of 0-RTT. It contains the cipher suite, the resumption secret, the server's certificate verification status, and optionally application-specific data like ALPN negotiation results. The ticket's lifetime determines how long clients can attempt 0-RTT resumption — longer lifetimes increase 0-RTT hit rates but expand the replay window.

# QUIC 0-RTT session ticket structure (conceptual)
class SessionTicket:
    def __init__(self):
        self.ticket_nonce = os.urandom(16)
        self.cipher_suite = "TLS_AES_256_GCM_SHA384"
        self.resumption_secret = derive_resumption_secret(
            handshake_secret, "res master"
        )
        self.ticket_age_add = random.randint(0, 2**32)
        self.max_early_data_size = 16384  # bytes
        self.lifetime = 7200  # seconds (2 hours)
        self.creation_time = time.time()

        # CDN-specific extensions
        self.server_name = "edge-sjc-01.cdn.example"
        self.transport_params = {
            "initial_max_data": 10485760,
            "initial_max_streams_bidi": 128,
            "max_idle_timeout": 30000,
        }

    def encrypt(self, ticket_key):
        """Server encrypts ticket with rotating key."""
        plaintext = serialize(self.__dict__)
        return aead_encrypt(ticket_key, self.ticket_nonce, plaintext)

    def is_valid(self, now):
        age = now - self.creation_time
        return age < self.lifetime

CDN Ticket Key Distribution

For 0-RTT to work across a CDN's edge network, all edge servers in a PoP (or ideally all PoPs) must share ticket encryption keys. A client that receives a session ticket from edge server A and reconnects to edge server B can only resume with 0-RTT if server B can decrypt the ticket. CDN operators solve this with a centralized ticket key distribution system that rotates keys on a schedule (typically every few hours) and distributes them to all edge servers.

# Ticket key rotation with overlap window
class TicketKeyManager:
    def __init__(self):
        self.current_key = None
        self.previous_keys = []
        self.rotation_interval = 3600  # 1 hour

    def rotate(self):
        if self.current_key:
            self.previous_keys.append(self.current_key)
        self.current_key = TicketKey(
            key_id=os.urandom(4),
            key_material=os.urandom(32),
            created_at=time.time()
        )
        # Keep previous keys for 2 rotation intervals
        # so tickets encrypted with old keys still work
        cutoff = time.time() - (2 * self.rotation_interval)
        self.previous_keys = [
            k for k in self.previous_keys
            if k.created_at > cutoff
        ]

    def decrypt_ticket(self, encrypted_ticket):
        """Try current key first, then previous keys."""
        for key in [self.current_key] + self.previous_keys:
            try:
                return aead_decrypt(
                    key.key_material,
                    encrypted_ticket
                )
            except DecryptionError:
                continue
        raise TicketDecryptionFailed()

The Replay Attack Problem

0-RTT data has a fundamental security limitation: it can be replayed. Because the client sends 0-RTT data before the handshake establishes freshness (the handshake's random values prove both parties are live), an attacker who captures the initial packet can re-send it to the server. The server, seeing a valid session ticket and properly encrypted data, will process the request again.

For CDN workloads, the impact depends on the request type. A replayed GET request for a cached image is harmless — the server returns the same cached response. A replayed POST request that submits a form, triggers a payment, or modifies a database record is catastrophic. This is why the HTTP specification designates GET and HEAD as safe (idempotent) methods and why CDN security configurations must restrict 0-RTT to these methods.

0-RTT Replay Attack Scenario and Mitigation 0-RTT Replay Attack and Mitigation Without Anti-Replay Client Attacker Edge 0-RTT: GET /page captures packet Replay: GET /page Replay: GET /page Accepted! With Strike Register Client Attacker Edge 0-RTT: GET /page recorded in SR Replay attempt Rejected (dup)
Figure 2: Without anti-replay protection, an attacker can capture and resend 0-RTT packets. A strike register detects and rejects duplicate 0-RTT client hellos

Anti-Replay Strategies for CDN Edge

CDN operators implement several anti-replay mechanisms, each with different trade-offs between security, performance, and operational complexity.

Strike Register

A strike register is a time-windowed data structure that stores a hash of each 0-RTT ClientHello. When the edge server receives a 0-RTT packet, it checks the register: if the ClientHello hash exists, the 0-RTT data is rejected (the connection falls back to 1-RTT). If not, the hash is recorded and the 0-RTT data is accepted. The register only needs to cover the maximum ticket lifetime — entries older than the ticket window can be evicted.

The challenge at CDN scale is coordination. If edge servers within a PoP do not share their strike registers, an attacker can replay to a different server in the same PoP. Solutions include shared in-memory stores (Redis-based registers) or partitioning: using anycast routing to ensure the same client IP consistently reaches the same server within a PoP.

Single-Use Tickets

An alternative to tracking replays is making replay impossible by design. With single-use tickets, the server issues a new session ticket for every connection and marks each ticket as consumed after its first 0-RTT use. This requires a distributed ticket state store (the server must check that a ticket has not been used at any other edge server), which adds latency to 0-RTT acceptance — somewhat defeating the purpose.

Bounded 0-RTT Window

A pragmatic approach used by several CDN providers is to limit the 0-RTT acceptance window to a very short duration — typically 10-60 seconds. The session ticket lifetime can be hours (for 1-RTT resumption), but 0-RTT is only accepted if the ticket age is within the bounded window. This narrows the replay opportunity while maintaining high 0-RTT acceptance rates for active browsing sessions where connections are reestablished frequently.

# CDN edge 0-RTT acceptance logic
def accept_zero_rtt(client_hello, early_data):
    ticket = decrypt_session_ticket(client_hello.psk_identity)
    if not ticket:
        return Reject("invalid ticket")

    # Check ticket age
    ticket_age = time.time() - ticket.creation_time
    if ticket_age > MAX_0RTT_WINDOW:  # e.g., 30 seconds
        return Reject("ticket too old for 0-RTT")

    # Check strike register
    ch_hash = hash(client_hello.serialize())
    if strike_register.contains(ch_hash):
        return Reject("replay detected")
    strike_register.add(ch_hash, ttl=MAX_0RTT_WINDOW)

    # Restrict to safe methods
    request = parse_early_data(early_data)
    if request.method not in ("GET", "HEAD"):
        return Reject("unsafe method in 0-RTT")

    # Check if request targets cacheable resource
    if not is_cacheable_route(request.path):
        return Reject("non-cacheable route")

    return Accept(request)

Production Deployment Patterns

Deploying QUIC 0-RTT on CDN infrastructure requires coordination between the QUIC stack, the HTTP layer, and the origin shield. The implementation varies by QUIC library, but the general pattern is consistent.

Edge Server Configuration

# nginx-quic configuration for 0-RTT
server {
    listen 443 quic reuseport;
    listen 443 ssl;

    ssl_protocols TLSv1.3;
    ssl_early_data on;

    # Session ticket configuration
    ssl_session_tickets on;
    ssl_session_timeout 2h;
    ssl_session_ticket_key /etc/nginx/ticket_keys/current.key;
    ssl_session_ticket_key /etc/nginx/ticket_keys/previous.key;

    # QUIC-specific settings
    quic_retry on;  # Enable address validation for new connections

    # Restrict early data to safe handlers
    location /api/ {
        # Reject 0-RTT for API endpoints
        if ($ssl_early_data) {
            return 425;  # Too Early
        }
        proxy_pass http://origin;
    }

    location /static/ {
        # Accept 0-RTT for static assets
        proxy_set_header Early-Data $ssl_early_data;
        root /var/www/cdn;
    }
}

# Ticket key rotation cron (hourly)
# 0 * * * * /usr/local/bin/rotate-ticket-keys.sh

Origin Protection

Even when the CDN edge accepts 0-RTT data, the origin server should be aware that the request may be replayed. The standard mechanism is the Early-Data: 1 HTTP header, which the edge server sets on requests derived from 0-RTT data. The origin can use this header to apply additional caution — serving cached responses but not executing mutations, for example.

# Origin handler: respect Early-Data header
def handle_request(request):
    is_early = request.headers.get("Early-Data") == "1"

    if is_early:
        # Only serve from cache, never execute side effects
        cached = cache.get(request.path)
        if cached:
            return cached
        # Return 425 Too Early to force 1-RTT retry
        return Response(status=425, headers={
            "Retry-After": "0"
        })

    # Full request processing for 1-RTT connections
    return process_normally(request)

Measuring 0-RTT Effectiveness

Track these metrics to evaluate your 0-RTT deployment. The 0-RTT offer rate measures what percentage of connections attempt 0-RTT (clients with valid session tickets). The 0-RTT acceptance rate measures how many of those offers succeed. The 0-RTT rejection breakdown categorizes rejections by cause: expired ticket, strike register hit, unsafe method, non-cacheable route. A healthy deployment shows 60-80% offer rate (returning visitors) with 90%+ acceptance rate among offers.

# 0-RTT metrics collection
class ZeroRTTMetrics:
    def record(self, event_type, details):
        labels = {
            "pop": self.pop_id,
            "event": event_type,
            "reason": details.get("reason", ""),
        }
        self.counter.increment(labels)

    # Example log analysis
    # 0-RTT offer rate: 72%
    # 0-RTT acceptance rate: 94%
    # Rejection breakdown:
    #   ticket_expired: 3.2%
    #   unsafe_method: 1.8%
    #   replay_detected: 0.01%
    #   non_cacheable: 0.99%

For CDN operators evaluating the performance impact, the key comparison is time-to-first-byte (TTFB) for 0-RTT connections versus 1-RTT connections, segmented by client distance to the edge. The improvement is most dramatic for high-RTT clients: a 200ms RTT client saves a full 200ms with 0-RTT, while a 10ms RTT client saves only 10ms. Prioritize 0-RTT optimization for edge PoPs serving regions with high last-mile latency.

QUIC 0-RTT represents the theoretical minimum for connection establishment — zero round trips before application data flows. Combined with HTTP/3 multiplexing and intelligent edge caching, it delivers the kind of instant-loading experience that separates leading CDN platforms from the rest. The security trade-offs are real but manageable with proper anti-replay mechanisms and method restrictions. For CDN operators, the question is not whether to deploy 0-RTT, but how aggressively to expand its scope.

External References

References & Standards: OWASP CDN Security · RFC 7234