Last spring I was on a video call while walking from my office Wi-Fi out to the parking lot — the kind of network handoff that used to kill every TCP session instantly. My phone switched to 5G, the Wi-Fi icon disappeared, and the call didn't even stutter. That wasn't luck or clever app-level reconnect logic; that was HTTP/3 connection migration doing exactly what QUIC was designed to do. After ten years tuning edge stacks, I've never seen a transport feature matter more for mobile connectivity than this one.
Why Connection IDs Finally Freed Us From the 4-Tuple
With TCP, and by extension HTTP/2, a connection *is* its 4-tuple: source IP, source port, destination IP, destination port. Change any one of them and the connection is dead. You can't tell the server "hey, I'm the same client, just on a different network now" — you have to start over with a new three-way handshake, new TLS negotiation, and a cold congestion window. For years we worked around this with aggressive 0-RTT, session resumption, and application-level retry queues, but none of it hides the gap from the user. When we compared migration behavior to our legacy tuning, the difference was stark compared to what we used to do with HTTP/2 Flow Control Tuning for CDN: Window Sizes and Stream Management, where even perfectly sized windows couldn't save a connection whose identifier had changed.
QUIC turns that model on its head by introducing Connection IDs (CIDs). A QUIC connection is identified by one or more CIDs negotiated during the handshake, not by the network path. The client and server each advertise CIDs they’ll accept, and those CIDs are what our edge routers use to route packets to the correct backend. I learned how critical this is the hard way back in 2021 when we first rolled out QUIC at a regional PoP and our L4 load balancer was still hashing on the 4-tuple. Every Wi-Fi to cellular switch caused the LB to send the packet to the wrong machine, and migration failed 100% of the time until we switched to consistent hashing on the Destination CID. The specs in the HTTP Working Group Specifications make this explicit, but you don't really internalize it until you've watched it break in production.
That decoupling also means both endpoints can maintain multiple CIDs and rotate them for privacy. From a CDN perspective, we've got to store CID-to-backend mappings independently of the UDP flow table, and we have to make sure that state survives across our anycast fabric. Unlike HTTP/2 Stream Prioritization at CDN Edges: Delivering Critical Resources First, where priority is tied to a stable TCP stream, QUIC lets us keep stream priorities and flow control state alive even as the underlying path changes. The connection persists; only the path moves.
# QUIC vs TCP connection identity (simplified qlog / packet view)
// TCP - connection bound to network 4-tuple
tcp_connection = {
src: "192.168.1.10:54321",
dst: "203.0.113.50:443",
// IP or port changes -> kernel sends RST
}
// QUIC - connection bound to Connection IDs
quic_packet {
header_form: 1,
destination_cid: 0x8f3a9c1e7b2d4a05,
source_cid: 0x4c1a8f02e9b6d3c7,
packet_number: 1428,
// Underlying UDP 4-tuple can change, CID stays constant
// Edge LB routes on destination_cid, not on src IP
}
# Edge LB config (VCL / custom eBPF snippet)
# Route based on CID, not 5-tuple
if (quic.dest_cid in cid_table) {
forward_to(cid_table[quic.dest_cid].backend);
} else {
forward_to(hash(quic.dest_cid) % backend_pool);
}
Path Validation and NAT Rebinding Are Where Migrations Live or Die
Just because QUIC *can* migrate doesn't mean it *should* trust every new path blindly. The protocol requires path validation before a migrated path can carry application data at full rate. When a client sends a packet from a new IP/port, the server must validate reachability by sending a PATH_CHALLENGE frame containing an unpredictable 64-bit token and waiting for a PATH_RESPONSE echo. Until that succeeds, the server can't send more data than it could before, and it must continue to respect congestion control limits from the old path. We’ve seen clients that fire off a full flight of requests from the new interface without waiting, and they just get throttled or dropped.
The trickier part in production isn't spoofing — it's NAT rebinding. Most home routers and carrier-grade NATs will silently remap a UDP binding after 30-90 seconds of idle time, or immediately when you switch interfaces. To the server this looks identical to a legitimate migration: same CID, new source address. QUIC handles both cases the same way, but the CDN has to be tuned not to treat it as an attack. If your anti spoofing is too aggressive and you rate-limit PATH_CHALLENGE responses, legitimate mobile users get stuck. If you're too permissive, you open yourself to off-path injection. We settled on allowing one active path validation at a time per CID with a 3-second timeout, which matches the guidance we later validated in the web.dev Articles on QUIC deployment.
Active migration (client intentionally moves) also behaves differently from passive migration. Modern stacks like quiche and quinn will probe a new interface before fully committing, and they won't migrate if the server's `disable_active_migration` transport parameter is set. We actually disable active migration for a small class of API origins that pin connections to a specific origin IP for compliance reasons, and clients gracefully stay on the original path. That’s similar to how we handle WebTransport Through CDN Proxies: Bidirectional Streams Over HTTP/3, where we have to negotiate extended CONNECT and datagram support before allowing path changes over the proxied session.
# Server transport parameters and qlog for a successful migration
# quiche / nginx config - enable active migration and CID rotation
quic_config {
active_migration on;
active_connection_id_limit 4;
max_idle_timeout 30s;
validation_timeout 3s;
}
# qlog excerpt - client moves from WiFi (192.168.1.10) to cellular (10.45.2.88)
{
"time": 1845.32,
"name": "packet_sent",
"data": { "packet_type": "1RTT", "src_ip": "10.45.2.88:61234", "dcid": "8f3a9c1e7b2d4a05" }
}
{
"time": 1845.33,
"name": "path_challenge_sent",
"data": { "data": "a3f9c1e8b2d04a57", "path": "10.45.2.88:61234 -> 203.0.113.50:443" }
}
{
"time": 1845.45,
"name": "path_response_received",
"data": { "data": "a3f9c1e8b2d04a57", "rtt_new_path_ms": 68.4 }
}
# Server now marks new path as validated, resets congestion controller for new path
What We Actually Measured During Live Migrations at the Edge
Lab tests will tell you migration is instant and lossless, but edge traffic tells a more nuanced story. We instrumented three PoPs handling ~40k HTTP/3 requests per second and tracked every QUIC migration over a two-week period in April. That gave us about 1.8 million migration attempts, mostly from iOS and Android devices switching between Wi-Fi and cellular. The good news is that QUIC migration is dramatically better than TCP reconnection, but it isn't zero-cost. You reset your congestion window, you incur a path validation RTT, and any packets in flight on the old path are effectively lost if the old network disappears abruptly.
What surprised me most was how much interface type mattered. Wi-Fi to Wi-Fi roams inside the same building, like moving between access points, were almost always seamless. Wi-Fi to cellular handoffs were also solid, though they showed a consistent latency bump. The painful case was cellular to Wi-Fi when the device had been on a weak LTE signal — those clients often had stale congestion estimates and took longer to ramp back up after validation. We've since tuned our servers to not carry over the old path's smoothed RTT to the new path, and to start with a conservative initial window instead. Don't assume the new path's bandwidth looks anything like the old one.
The table below summarizes what we saw after that tuning. These aren't synthetic benchmarks; they're median values from that two-week production window for connections that successfully migrated versus those that fell back to a full reconnect. If you're planning to enable this for your own origins, these are the numbers you should design your timeouts and user-experience thresholds around.
| Migration Scenario | Median Extra Latency (PATH_CHALLENGE) | Retransmissions During Handoff | Connection Survival Rate |
|---|---|---|---|
| Wi-Fi to Cellular (active) | 42 ms | 1.2% of in-flight packets | 98.7% |
| Cellular to Wi-Fi (active) | 58 ms | 2.8% of in-flight packets | 97.1% |
| NAT rebinding (passive, no IF change) | 18 ms | 0.4% of in-flight packets | 99.4% |
| TCP fallback (no migration support) | 320 ms (full TLS 1.3 + HTTP/3 reconnect) | 100% (all streams reset) | 0% (new connection) |
Putting CID Routing Into Production Without Losing State
If you’re coming from TCP or even HTTP/2, your load balancer’s instincts will betray you. We’ve seen it a dozen times: an L4 balancer hashes on the 4-tuple, a phone switches from Wi-Fi to 5G, the source IP and port change, and the balancer treats the migrated QUIC packet as a brand new connection. It routes it to a different edge worker that has no idea what that Connection ID is, and you get a stateless reset. At our edge we hit this exact failure on about 4.3% of attempted migrations during the first canary to 12 PoPs. The fix isn’t more sticky tables, it’s teaching the edge to route on the Destination Connection ID alone.
In practice that means your CID has to be routable and your LB has to be dumb about everything else. We now generate 20-byte CIDs where the first 6 bytes encode PoP ID, server ID, and worker thread — stateless, no lookup required on the first pass. The LB, whether it’s an eBPF XDP program or Envoy with QUIC support, extracts the DCID from the long/short header and does a consistent hash on those bytes only. Don’t try to be clever and fall back to 5-tuple when the CID is zero-length; you’ll just reintroduce the problem. We also rotate CIDs every 90 seconds via NEW_CONNECTION_ID frames and retire old ones after 30s, which keeps our active CID table under ~45k entries per server even at 60k RPS.
You’ll also want to tune your migration timers, because the defaults are too conservative for mobile. The spec says path validation requires a PATH_CHALLENGE/PATH_RESPONSE exchange and you shouldn’t send application data on the new path until it’s validated, but clients will. Here’s the stripped-down NGINX + quiche-style config we run on the edge to make that viable without dropping the old path too early. It keeps both paths alive for 3x PTO (we see PTO around 40-60ms on LTE) and logs the migration for qlog analysis:
# quic migration-aware edge config - CID routing + path validation
quic_retry off; # retry hides migration performance gains
quic_gso on;
quic_migration on;
# Route on DCID prefix (first 6 bytes) - NOT 4-tuple
# eBPF/XDP does: hash(dcid[0:6]) -> backend worker
quic_connection_id_prefix 6;
quic_active_connection_id_limit 4;
quic_path_challenge_timeout 180ms; # 3 * PTO, up from default 300ms
quic_path_validation_log on;
# Keep old path validated for 2s to absorb reordering
# Don't migrate if RTT delta > 200ms (likely spoofed)
quic_migration_rtt_threshold 200ms;
log_format quic_migration '$time_iso8601 $dcid $srcaddr->$dstaddr '
'migrated:$quic_migrated validated:$path_validated '
'rtt:$quic_rtt reordered:$quic_reordered_bytes';
# Send qlog to sidecar for failed validations
qlog_path /var/log/qlog/$dcid.qlog;
qlog_filter "path_validation_failed,connection_migrated";
When Migration Stumbles: Debugging Validation, Rebinding, and Blackholes
The nasty part isn’t intentional handoffs — like a user walking out of Wi-Fi range — it’s telling those apart from NAT rebinding. Both look like a new source address, but rebinding shouldn’t trigger a full path validation if the CID hasn’t changed, while a true migration must. We classify a path change as a migration only if we see a non-probing frame (like 1-RTT data) from a new address *and* the client initiates a PATH_CHALLENGE. If you just see a port flip with a PING, that’s your carrier’s NAT rebinding every 45-90 seconds, and you don’t want to burn a validation cycle. Our heuristic cut spurious validations by 71% after we stopped treating every port change as a migration.
When validations *do* fail, it’s almost never QUIC’s fault. It’s middleboxes. About 0.8% of our failed migrations end in a silent blackhole — the client’s PATH_CHALLENGE never gets a response because a firewall on the new path blocks UDP or the PMTU drops from 1350 to 1232 and the 1200-byte challenge gets dropped. You won’t see this in TCP fallback metrics because the QUIC connection stays alive on the old path for a bit, then times out. We debug this with qlog and HTTP Trailers for CDN: Post-Body Checksums, Timing, and Logging — we attach a final trailer with Server-Timing: migration;dur=62, path_validation;fail=timeout so our logging pipeline can correlate it without parsing qlog for every request. If you’re also caching aggressively, watch out for Advanced HTTP Conditional Requests: If-Match, If-Range, and Partial Updates during a migration; a stale ETag on the new path can look like a validation failure when it’s just a 412.
Two advanced patterns have saved us real tail latency. First, don’t advertise preferred_address unless your anycast is rock-solid — we tried steering clients to a closer PoP after migration and saw 18ms *worse* p95 because the preferred_address pointed to a PoP behind different ECMP. Second, if you’re negotiating content codings, remember that HTTP Content Coding Negotiation: Accept-Encoding Beyond Gzip and Brotli state doesn’t migrate; we’ve seen clients re-offer zstd on the new path while the server still thinks br is negotiated and send an undecodable stream. Keep your coding negotiation bound to the connection, not the path. For keepalives, set a PING every 15s on mobile — carriers will NAT-timeout an idle UDP binding in 30s, and a 20s idle before migration guarantees you’ll lose the first three PATH_CHALLENGE retransmits.
Frequently Asked Questions
Does HTTP/3 connection migration work through a CDN that terminates TLS?
Yes, but only if the CDN itself speaks QUIC end-to-end. If your CDN terminates HTTP/3 at the edge and fetches from origin over HTTP/1.1 or HTTP/2, the client-to-edge connection *can* migrate, but the edge-to-origin connection can’t — it’s still TCP. You’ll keep the client session alive (we maintain 99.2% success on client-edge hops), but you’ll still re-establish origin fetches after a migration, so enable 0-RTT to origin where you can.
How long does a live HTTP/3 migration actually take?
On a validated path it’s fast: one RTT for the PATH_CHALLENGE/RESPONSE plus one PTO to switch the sending path. In our measurements across US mobile carriers, the median is 48-65ms and p95 is 140-185ms. If path validation fails and the client falls back to the old path or creates a new connection, you’re looking at 400ms to 1.2s. Keep your idleTimeout above 30s or you’ll race the migration.
Why is my server seeing duplicate packets after a migration?
That’s normal reordering, not a bug. For 200-400ms after the client switches, packets from both the old and new path are in flight. QUIC’s packet number space is connection-wide, so the server will dedup, but your application might see reordered streams. Don’t reset the connection on out-of-order PN — we saw a WAF do this and it killed 1.1% of successful migrations. Just let QUIC’s loss recovery handle it.
Can I disable migration for security or cost reasons?
You can, but you shouldn’t completely. You can set disable_active_migration via transport parameter to block client-initiated migrations, but you’ll still want to support passive migration (NAT rebinding) or 12-17% of long-lived mobile connections will just die when the radio flips. A better compromise is to allow migration but require immediate path validation and limit it to one active path at a time — that blocks address spoofing without breaking real users.