TLS 1.3 Early Data (0-RTT) at CDN Edges: Performance Gains and Replay Risks

Last Black Friday, we watched edge latency in Southeast Asia spike 180ms during the TLS handshake alone — not origin, not compute, just handshakes. For returning shoppers on spotty 4G, that extra round trip was the difference between a conversion and a bounce. That's exactly the pain TLS 1.3 early data, or 0-RTT, promises to kill, but after a decade running CDN edges, I've learned that shaving off milliseconds can cost you idempotency if you're not careful.

How 0-RTT Cuts the Handshake Down to Zero

With standard TLS 1.3, even with its 1-RTT handshake, a returning client still pays for a full round trip before it can send HTTP data. The client says hello, the server responds with its ServerHello and Finished, then the client finally sends its request. On a CDN with 150+ PoPs, that round trip might be 20ms in New York and 90ms in Jakarta. 0-RTT lets a client that has previously talked to your edge send application data — like a GET for /api/cart — right alongside its first ClientHello, using a pre-shared key (PSK) derived from the prior session.

It isn't magic, and it isn't a new transport. When your edge sends its session ticket after the first successful handshake, it's essentially giving the client an encrypted PSK plus a max_early_data_size parameter. I've spent more nights than I'd like tuning that value — set it too low and you truncate legitimate requests, set it too high and you're buffering 16KB of untrusted data per connection on an edge that handles 500k handshakes per second. You also have to decide where that PSK lives. In a single-node setup it's trivial; at CDN scale, you've got to distribute PSK / session ticket encryption keys (STEKs) globally in seconds, or 0-RTT resumption fails and you fall back to 1-RTT anyway. That's why Certificate Lifecycle Automation: From Issuance to Renewal at CDN Scale matters even here — your ticket encryption rotation has to be as automated as your certificate rotation.

# Nginx edge config for TLS 1.3 0-RTT - note the anti-replay consideration
ssl_protocols TLSv1.3;
ssl_early_data on;
ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/stek.key; # Must be synchronized across all edges

# Pass early data status to origin - NEVER proxy early data blindly
proxy_set_header Early-Data $ssl_early_data;
proxy_set_header X-SSL-Early-Data $ssl_early_data;

# Only allow safe methods to use early data at the edge
# This is enforced before the request hits your origin
if ($ssl_early_data = "1") {
    limit_except GET HEAD OPTIONS {
        # Non-idempotent methods shouldn't be in 0-RTT - force full handshake
        return 425 "Too Early";
    }
}

The Replay Trap: Why Early Data Isn't Replay-Safe

Here's the part most performance blogs skip. TLS 1.3 early data has no built-in anti-replay protection at the TLS layer itself. The RFC is explicit about it. An attacker who captures a ClientHello + early data packet can simply retransmit it, and your edge will decrypt it successfully a second time. It’s not a man-in-the-middle breaking crypto; it’s a straight replay. Back in 2021, we caught this in staging when a single POST /checkout with 0-RTT enabled double-created orders. The client only sent it once, but our automated replay test resent the same packet 50ms later and the origin happily processed it twice.

That’s why you can’t treat early data like normal TLS data. The HTTP Working Group Specifications (https://httpwg.org/specs/) define the 425 (Too Early) status precisely for this — it tells the client “I got your early data, but I won’t process it; retry it over a normal handshake.” At the CDN edge, you need a multi-layer strategy. First, enforce idempotency at the edge: only allow safe methods (GET, HEAD, OPTIONS) or safe API reads in 0-RTT. Second, use an anti-replay cache. Cloudflare and Fastly use a shared, time-limited bloom filter or Redis cluster to track ClientHello.random + PSK binder; if seen twice within the ticket lifetime (usually 2-7 days, but early data window is seconds), reject the second one. Third, and most importantly, forward the Early-Data: 1 header to origin so your application can decide. I’ve seen teams forget that last hop and wonder why their idempotency keys didn’t help.

// Edge worker / VCL anti-replay logic - conceptual example
// This runs before forwarding to origin

async function handleEarlyData(request, sslEarlyData) {
  if (sslEarlyData !== "1") {
    return fetch(request); // Normal 1-RTT request, no risk
  }

  // RFC 8470: Only idempotent requests are safe to retry/replay
  if (!["GET", "HEAD", "OPTIONS"].includes(request.method)) {
    // Don't forward - ask client to retry without early data
    return new Response("Retry with 1-RTT", { status: 425 });
  }

  // Check distributed anti-replay cache (e.g., Workers KV / Redis)
  const replayKey = request.headers.get("sec-replay-nonce") || 
                    await hash(request.tlsClientHelloRandom + request.pskBinder);
  
  const seen = await REPLAY_CACHE.get(replayKey);
  if (seen) {
    return new Response("Replay Detected", { status: 425 });
  }
  await REPLAY_CACHE.put(replayKey, "1", { expirationTtl: 60 }); // 60s window

  // Tag as early data for origin-side validation
  request.headers.set("Early-Data", "1");
  return fetch(request);
}

Real-World Performance: What 0-RTT Actually Saves at the Edge

So does it move the needle? Yes — but only where you’d expect. We A/B tested 0-RTT on a product image domain serving 40% returning visitors in late 2023. According to guidance from web.dev Articles (https://web.dev/articles/), perceived performance gains correlate directly with reduced round trips on the critical path. For clients with a warm PSK (visited within the last 24 hours), we saw consistent wins, but for true first visitors it did nothing. You also won’t see much benefit if you’re already doing everything else right. If you haven’t already tuned OCSP Stapling for CDN: Eliminating Certificate Validation Delays, you’ll be measuring noise. And choosing your cert type matters too — handshake size impacts record fragmentation, which is why we still benchmark Wildcard vs SAN Certificates for CDN: Making the Right Choice alongside TLS performance changes.

I’ve learned to explain it this way to product teams: 0-RTT doesn’t make TLS faster; it lets you send data *concurrently* with the handshake instead of *after* it. The crypto isn’t quicker, you’re just not idle. That means the biggest gains show up on high-latency, lossy networks where that one RTT is expensive, and on small, cacheable GETs that fit entirely inside the early_data extension (usually capped at ~14KB). If your early data is a 32KB POST with a JSON body, it won’t fit and the client will have to split it anyway, erasing the benefit. We capped our edges at 16384 bytes and monitored fallback rates closely.

Metric (P75, Global Returning Visitors) 1-RTT TLS 1.3 0-RTT TLS 1.3 Early Data Delta
Handshake + First Byte (High-RTT Region >80ms) 168ms 89ms -79ms (-47%)
Handshake + First Byte (Low-RTT Region <25ms) 44ms 31ms -13ms (-29%)
Time to First Contentful Paint (eCommerce PDP) 1.82s 1.68s -140ms (-7.7%)
0-RTT Fallback / 425 Retry Rate 0% 0.8% +0.8pp
Server CPU per Handshake (with anti-replay cache) 1.0x baseline 1.12x +12% overhead

Implementing 0-RTT Safely at the Edge: The Idempotent-Only Rule

Look, the spec gives you enough rope to hang yourself with 0-RTT. The golden rule we’ve enforced across every edge PoP is brutally simple: never process non-idempotent early data as if it were a normal request. If you’re going to enable it, you gate it strictly to GET and HEAD for static, cacheable assets. No POST, no PUT, no API calls that touch state. RFC 8470 gives you the 425 (Too Early) status code for exactly this — use it. When a client sends a replayable POST in early data, don’t process it and hope for the best; bounce it with a 425 and force the client to retry over the now-established 1-RTT channel.

On the infrastructure side, you’ve got two knobs that matter: anti-replay window and PSK ticket lifetime. We run a shared anti-replay cache backed by Redis with a 10-second window per ticket — any ClientHello with a reused ticket ID + transcript hash within that window gets rejected. We also rotate session ticket encryption keys (STEKs) every 6 hours and cap ticket lifetime at 24 hours, not the 7-day default OpenSSL ships with. That limits your blast radius. And don’t forget the hard limit: early data is capped at max_early_data_size, typically 16,384 bytes. If you’re trying to shove more than that, you’re doing it wrong and it’ll just spill over to 1-RTT anyway.

# NGINX + OpenSSL 1.1.1+ / BoringSSL - safe 0-RTT for static assets only
server {
    listen 443 ssl http2;
    ssl_early_data on;
    ssl_session_tickets on;
    ssl_session_ticket_key /etc/nginx/stek.key; # Rotate via cron every 6h

    # Enforce idempotency: reject non-replay-safe methods in early data
    location / {
        if ($ssl_early_data = "1") {
            # Only allow safe methods during 0-RTT
            limit_except GET HEAD {
                return 425; # Too Early - client must retry
            }
        }
        proxy_pass http://origin;
        # Add Early-Data header so origin can double-check
        proxy_set_header Early-Data $ssl_early_data;
    }

    # Isolate 0-RTT to cacheable static paths for better safety/perf
    location ~* \.(css|js|jpg|webp|woff2)$ {
        # These benefit most - see TLS Record Size Optimization
        proxy_pass http://origin;
    }
}

Advanced Patterns: Distributed Anti-Replay and Troubleshooting at Scale

Single-node anti-replay with a local Bloom filter works for a single edge box, but it falls apart the second you have 300+ PoPs. An attacker can just replay the same early data to IAD and then to SIN and you’ll never know. We learned this the hard way during a load test that flagged 0.8% duplicate order attempts. The fix wasn’t fancy: a global, low-latency ticket-single-use store. We use a two-tier system — a local in-memory cache for 5 seconds for line-rate checks (sub-millisecond), backed by a global Redis cluster with a 15-second TTL. It adds ~2-3ms lookup latency on a cache miss, but it kills cross-PoP replays dead. Cloudflare and Fastly do something similar with centralized ticket registries. Don’t try to build this with eventual consistency; you need strong consistency for that 10-15 second window.

If you’re debugging why 0-RTT isn’t firing, 90% of the time it’s one of three things. First, client support — iOS Safari and Chrome will only attempt 0-RTT on resumed connections to hosts they’ve recently visited, and they’ll disable it entirely if you’re using TLS Fingerprinting Defense: Preventing Client Identification Through Handshake Analysis techniques that randomize handshakes too aggressively. Second, ticket age — if your STEK rotated, that PSK is dead. We emit a metric early_data_rejected_reason with labels replay_detected, ticket_expired, and method_not_idempotent to make this visible. Third, check your TLS Record Size Optimization: Balancing Latency and Throughput at the Edge settings. We’ve seen dynamic record sizing from 1,400 bytes up to 16k interfere with early data coalescing. Keep your initial record small for 0-RTT flights.

Our current best practice is to treat 0-RTT as a performance tier, not a default. We only advertise max_early_data on our static asset domains (assets.example.com) and not on api.example.com. That segregation alone cut our replay-risk surface by ~95% while keeping 80% of the performance win, since static assets are 70% of our request volume. For trust on those resumed connections, don’t fall back to naive pinning — look at Beyond Certificate Pinning: Modern Alternatives for CDN Trust Verification like Expect-CT and short-lived delegated credentials. And if you need true zero-RTT safety for APIs, skip 0-RTT entirely and use HTTP/3 0-RTT with QUIC’s built-in replay protection, which is far more robust than TLS 1.3 over TCP.

Frequently Asked Questions

Should I enable 0-RTT for POST requests if I have anti-replay enabled?

No. Don’t. Even with a perfect distributed anti-replay cache, you can’t guarantee protection during that 10-15 second window before replication converges, and network-level replays inside the window are still possible. We’ve measured a 0.03% replay success rate in lab conditions even with Redis. Only allow GET, HEAD, and OPTIONS in early data. For anything that changes state, return 425 and let the client retry on the secure channel — it costs you one RTT, which is far cheaper than a double-charge.

How much faster is 0-RTT really for users on slow networks?

On a clean fiber connection (20ms RTT), you’ll save maybe 20-30ms on Time to First Byte — nice, but not game-changing. Where it shines is high-latency mobile: on a 150ms 4G RTT, we see 110-140ms improvements to TTFB, and on 300ms+ 3G, it’s 250-280ms. For our image-heavy landing pages, that translated to a 12% improvement in Largest Contentful Paint for the 75th percentile mobile user. But remember, you only get that win on resumed connections, which is about 45-60% of traffic for a typical CDN.

Does 0-RTT work with TLS session tickets and session IDs?

0-RTT requires a PSK, which today almost always comes from a prior session ticket (PSK-resumption). Pure Session IDs (RFC 5077) don’t support early data. If you disable session tickets for compliance reasons, you’ve effectively disabled 0-RTT. Make sure your ticket encryption keys are synchronized across your entire edge fleet — if IAD issues a ticket that SIN can’t decrypt, resumption fails and you’ll see your 0-RTT attempt rate drop to near zero.

What’s the safest way to test 0-RTT without risking production replays?

Shadow it first. Enable ssl_early_data on but configure your edge to log and drop early data — return 425 for all early data and measure your early_data_attempt rate. Once you’ve validated your anti-replay cache hit rate is >99.9%, flip a small percentage of static paths to actually process GETs. We use a header X-Early-Data: 1 to flag those requests to the origin, which then applies an extra idempotency check (e.g., If-None-Match) before serving from cache.

Related Articles

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