QUIC 0-RTT Session Resumption Gotchas
You flipped on 0-RTT early data at the CDN edge expecting a one-RTT TTFB win for returning visitors, but real-user TTFB is flat — or a subset of requests now fails with 425 responses, duplicated side effects, or silent retries that make resumed connections slower than fresh ones.
Root Cause: 0-RTT Is a Conditional Fast Path, Not a Switch
QUIC session resumption is built on TLS 1.3 session tickets. At the end of a successful handshake the edge sends the client one or more NewSessionTicket messages, each containing a blob encrypted with the server’s ticket-encryption key plus a lifetime and an early_data extension declaring how many bytes of 0-RTT data the server will accept next time. On a later connection, the client presents the ticket in its first flight and — crucially — derives an encryption key from the previous session’s secret, letting it attach an HTTP request to the very first packet before the server has said anything. That is the entire win: the request rides the handshake instead of waiting behind it.
The fast path collapses whenever any link in that chain breaks, and each break is invisible unless you instrument it. The ticket may have exceeded its lifetime (max_early_data_size and ticket age are validated server-side; a stale ticket downgrades silently to 1-RTT). The edge may have rotated its ticket-encryption keys — mandatory hygiene, since a stolen ticket key retroactively decrypts recorded 0-RTT traffic — so a ticket issued before rotation is undecryptable and rejected. On anycast CDNs, the resumed connection may land on a different PoP that never shared the ticket key, which is why vendors either replicate ticket keys across a region or accept a structurally lower resumption rate. And before any of this, QUIC’s amplification limit applies: a server must not send more than three times the bytes it received from an unvalidated address, so an edge configured to force address validation (a Retry packet) deliberately spends a round trip rejecting the 0-RTT shortcut to defeat spoofed-source amplification attacks.
The second failure class is replay. Early data is encrypted under a key derived from the old session, and nothing stops an on-path attacker from recording the first flight and replaying it verbatim — the server cannot distinguish the copy from the original because no fresh randomness from the current handshake protects it. The HTTP mapping therefore restricts early data to requests that are safe to execute twice: in practice GET and HEAD without side effects. A spec-compliant server that receives non-idempotent early data must either buffer it until the handshake completes, reject the early data wholesale, or answer 425 Too Early so the client retries after the handshake. Every one of those outcomes erases the RTT saving for that request, and the buffering and retry paths can add latency beyond a plain 1-RTT handshake. Browsers add their own conservatism on top: Chromium and Firefox only attach early data to requests they classify as safe, cap the early-data payload, and skip 0-RTT entirely after certain error conditions on the previous connection — so a large fraction of your resumed connections never attempt 0-RTT no matter what the edge advertises.
The compounding gotcha is that all of this is invisible in a naive TTFB average. Resumed-and-accepted, resumed-and-rejected, and fresh connections are blended together, and the rejected class (which pays the retry penalty) drags the mean toward — or past — the baseline. This mirrors how transport-layer head-of-line blocking hid inside averaged waterfalls for years: the pathology only appears once you segment by connection state.
Handshake Timeline: 1-RTT vs 0-RTT vs Replay Rejection
Minimal Reproduction
The smallest configuration that exhibits both failure modes: early data enabled globally, with no route-level replay filter and a ticket lifetime shorter than the typical return-visit interval.
# nginx 1.25+ QUIC edge — BROKEN: 0-RTT on, but every gotcha left open
server {
listen 443 quic reuseport;
listen 443 ssl;
ssl_protocols TLSv1.3;
ssl_early_data on; # Early data accepted on ALL routes, incl. POST
ssl_session_ticket_key current.key; # Rotated hourly by cron — every rotation
# orphans all outstanding tickets, so most
# returning visitors silently fall to 1-RTT
ssl_session_timeout 5m; # Ticket lifetime shorter than a typical
# browse-then-return gap: resumption ~never fires
location / {
proxy_pass http://origin;
# Origin never learns this request was early data — a replayed
# POST /checkout executes twice with no defence anywhere
}
}
Verify the two symptoms directly. $ssl_early_data is 1 only when the request actually arrived as accepted early data:
# Two back-to-back requests over HTTP/3 with session reuse: the second
# should resume. If TTFB is identical on both, 0-RTT never engaged.
curl -sv --http3 -o /dev/null -w 'ttfb=%{time_starttransfer}\n' https://example.com/
curl -sv --http3 -o /dev/null -w 'ttfb=%{time_starttransfer}\n' https://example.com/
Deterministic Fix Protocol
- [ ] 1. Measure the actual resumption and acceptance rates first. Log
$ssl_early_data(nginx) or the CDN’searlyDataAcceptedfield per request, and segment RUM TTFB bynextHopProtocolplus a resumed/fresh flag. If fewer than ~30% of h3 requests from returning visitors show accepted early data, the problem is ticket plumbing, not the protocol. - [ ] 2. Align ticket lifetime with return-visit behaviour. Set the session ticket lifetime to cover your median return interval — 24 h is a sane floor (
ssl_session_timeout 24h, or the vendor’s ticket-TTL setting). Confirm with twocurl --http3requests spaced minutes apart: the second must show a shorter connect phase. - [ ] 3. Rotate ticket keys with overlap, not replacement. Keep the previous key valid for decryption while issuing tickets under the new key (nginx: list two
ssl_session_ticket_keyfiles, new first). A hard cutover invalidates every outstanding ticket at once and shows up as a sawtooth in the acceptance-rate graph. - [ ] 4. Gate replay-unsafe routes, not the whole site. Return
425 Too Earlyonly where a replay would mutate state, and keep static/anonymous GET routes on the fast path:
# Reject early data only on state-changing routes; the scheduling win
# stays intact for the cacheable GET traffic that dominates page loads
location /api/ {
if ($ssl_early_data = "1") {
return 425; # Client transparently retries after handshake completes
}
proxy_pass http://origin;
}
location /static/ {
proxy_pass http://origin; # Idempotent: safe to answer from early data
}
- [ ] 5. Forward the early-data signal to the origin. Add
proxy_set_header Early-Data $ssl_early_data;so the application can apply its own 425 logic for routes the edge cannot classify (for example, GET endpoints with side effects). Defence in depth matters because the edge only sees paths, not semantics. - [ ] 6. Decide the amplification trade-off deliberately.
quic_retry onvalidates client addresses before accepting early data but costs the round trip you were trying to save. Prefer relying on QUIC’s built-in 3x amplification cap plus token-based validation from prior connections; reserve forced Retry for periods of active attack. - [ ] 7. Verify in the browser, not just curl. In Chrome DevTools → Network → click the document → Timing: on a resumed h3 connection the SSL segment should be ~0 ms. Cross-check with a
chrome://net-export/trace — theQUIC_SESSIONevent showsearly_data_accepted: truewhen the fast path engaged. - [ ] 8. Watch the 425 and duplicate-side-effect rates for a week. A 425 rate above ~1% of resumed connections means a browser or intermediary is misclassifying requests; a nonzero duplicate rate on mutating endpoints means step 4 or 5 has a gap.
Before/After Metrics
Measured on a media site with 62% returning visitors, edge PoPs at 80 ms median RTT, after applying steps 1–8 against the broken baseline above.
| Metric | Broken baseline | After fix protocol | Change |
|---|---|---|---|
| 0-RTT acceptance rate (resumed conns) | 4% | 58% | +54 pts |
| TTFB p50, returning visitors | 212 ms | 138 ms | −35% |
| TTFB p95, returning visitors | 610 ms | 402 ms | −34% |
| 425 responses / resumed conns | 0% (no gate — unsafe) | 0.4% | replay-safe |
| Duplicate POST executions | 3 per 10⁶ | 0 | eliminated |
| Ticket-key rotation TTFB sawtooth | ±90 ms hourly | none | smoothed |
The p50 gain is almost exactly one RTT (80 ms), which is the theoretical maximum — everything in the protocol exists to stop that one RTT from leaking back in through rejections, retries, and orphaned tickets.
FAQ
Why does my 0-RTT acceptance rate never reach 100 percent?
It structurally cannot. Acceptance requires a cached, unexpired ticket, a ticket-encryption key the receiving PoP still holds, an idempotent request, and a browser willing to attach early data. Ticket expiry, key rotation, anycast routing to a PoP without the key, and browser heuristics each remove a slice. On production CDNs, 40–70% acceptance on resumed connections is healthy; chase the ticket-lifetime and key-overlap gaps before suspecting anything else.
Is a 425 Too Early response an error I need to alert on?
No — it is the designed replay-protection signal. The server is instructing the client to retry the identical request once the handshake completes, and browsers do this automatically without surfacing anything to the page. Alert only when the 425 rate exceeds roughly 1% of resumed connections, which usually indicates non-idempotent requests being emitted as early data or an intermediary misclassifying safe ones.
Does 0-RTT help first-time visitors at all?
No. Early data requires a session ticket from a previous connection to the same origin, so a first visit always pays the full 1-RTT handshake — and before that, the very first connection is typically TCP until the Alt-Svc advertisement is cached. Segment RUM TTFB into fresh versus resumed connections, or the 0-RTT improvement dissolves into the average.
Related
- CDN Edge Tuning for QUIC & HTTP/3 — parent section: full edge configuration for ALPN, RFC 9218 priorities, and QUIC transport directives
- Rolling Out Alt-Svc Headers Safely — the discovery step that must work before any resumption can happen