TL;DR: NAT traversal has historically relied on STUN/TURN/ICE running as an external signalling layer bolted onto whatever transport protocol you choose. A 2023 IETF draft by Marten Seemann (draft-seemann-quic-nat-traversal) proposes something different: use QUIC's own path validation mechanism, extended with three new frames (ADD_ADDRESS, PUNCH_ME_NOW, REMOVE_ADDRESS), to punch holes and migrate to direct paths natively. A 2024 measurement study confirms the theory that QUIC hole punching completes in 2–2.5 RTTs versus TCP's 2.5–3 RTTs, and connection migration for recovery saves 2–3 RTTs over re-punching. This piece breaks down the mechanism and what it means for anyone building peer-to-peer or relay-based networking tools.
Why Hole Punching Exists
NAT was never designed for peer-to-peer communication. It was a patch for IPv4 exhaustion map a private address behind a router to a public one, kept a session table, and only let traffic back in if it matched an existing outbound flow.
That works fine for client-server traffic. It breaks P2P by default, because neither peer can accept an unsolicited inbound connection.
The scale of this problem is bigger than most people assume. Research on the PPLive P2P streaming system found that roughly 80% of end nodes sit behind NAT. A Bitcoin network study found a small subset of publicly reachable nodes carries 89% of transaction propagation precisely because most peers can't be dialled directly. NAT doesn't just add friction; it concentrates load onto whichever nodes happen to be reachable.
Hole punching is the workaround: get both peers to send outbound packets toward each other at roughly the same time, so each NAT's session table records the other side's address as "already talked to," and inbound traffic slips through as if it were a reply.
The mechanics of why this works depend entirely on the NAT's mapping behaviour.
NAT Mapping Rules: The Foundation
Endpoint-Independent Mapping: A private node's mapping to a public address stays the same regardless of which external peer it's talking to. If P is mapped to nodeP when talking to N1, it's still nodeP when talking to N2. This is what makes hole punching reliable: a relay server can hand out P's public mapping to any peer.
Address and Port-Dependent Mapping: The mapping changes per destination. P talking to N1 gets nodeP1; P talking to N2 gets nodeP2. A mapping learned via one connection is useless for punching a hole to a different peer.
This distinction matters because it's the difference between hole punching working reliably and not working at all. Symmetric (address/port-dependent) NATs are the primary reason traversal fails in the real world, regardless of which transport protocol you use.
The Classic Hole Punching Sequence
With a relay server S and two clients A and B, both behind NAT:
1. A and B each connect to S and register their public and private addresses.
2. A requests B's address from S. S exchanges A's and B's public addresses.
3. A sends a connection request to B.
→ Hits NAT-B, gets dropped (no session entry exists yet on NAT-B for A→B)
→ But creates a session entry on NAT-A for A→B
4. B sends a connection request to A.
→ Hits NAT-A, and now MATCHES the session entry NAT-A just created
→ Passes through, reaching A
5. The "hole" is now open in both directions. Direct communication begins.
This sequence is protocol-agnostic in concept. What differs is how expensive each step is, depending on whether you're running TCP or QUIC underneath.
Where TCP Hole Punching Breaks Down
TCP hole punching carries three structural penalties:
1. Port multiplexing is mandatory. TCP sockets are one-to-one; a local port can only be bound to a single socket. To simultaneously listen for an inbound connection and initiate an outbound one on the same port (required for hole punching), you need SO_REUSEADDR/SO_REUSEPORT tricks and careful socket lifecycle management. QUIC has no such restriction; multiple "connections" can share a UDP socket natively, since QUIC demultiplexes by Connection ID, not by the OS socket layer.
2. Handshake cost stacks with TLS. TCP's three-way handshake is 1.5 RTTs before you've sent a single encrypted byte. TLS 1.3 on top adds another handshake because TCP operates in kernel space and can't natively fold TLS negotiation into its own handshake. QUIC integrates TLS 1.3 directly into the transport handshake, so encryption setup and connection setup happen in the same round trips.
3. Recovery means starting over. If a punched TCP connection drops because a mobile client switched networks or a NAT session timed out, there is no lightweight recovery path. You re-punch from scratch: new relay round trip, new three-way handshake, new TLS negotiation.
The IETF Draft: draft-seemann-quic-nat-traversal
Authored by Marten Seemann (Protocol Labs and the author of quic-go), this draft defines a QUIC extension that turns the protocol's existing path validation machinery into a traversal primitive without requiring a separate STUN stack.
The core idea
QUIC already has a mechanism for verifying a new network path is viable: path validation, using PATH_CHALLENGE and PATH_RESPONSE frames (RFC 9000, §8.2). This exists for connection migration, proving a new IP: port pair actually works before switching the active connection to it.
The draft's insight: if a client can trigger path validation on an unverified address, one that hasn't been active on the connection yet, and the server does the same on its end simultaneously, the packets sent during validation create the NAT bindings needed for a direct path. Hole punching, essentially, for free, using the infrastructure QUIC already has.
Critically, RFC 9000 assumes servers can already receive packets on a path without needing to create a NAT binding first. This draft explicitly extends path validation to work on the server side too, specifically to accommodate NAT traversal.
Three new frames
ADD_ADDRESS server → client only. Advertises an address candidate.
ADD_ADDRESS Frame {
Type (i) = 0x3d7e90..0x3d7e91,
Sequence Number (i),
[ IPv4 (32) ],
[ IPv6 (128) ],
Port (16),
}
Sent incrementally as candidates are discovered, mirroring Trickle ICE (RFC 8838), rather than waiting for full candidate gathering to complete. Address matching (pairing local and remote candidates) happens entirely client-side, using ICE's pairing algorithm (RFC 8445 §5.1) as a reference, though implementations are free to diverge.
PUNCH_ME_NOW client → server only. Requests that the server begin path validation on a specific candidate pair.
PUNCH_ME_NOW Frame {
Type (i) = 0x3d7e92..0x3d7e93,
Round (i),
Paired With Sequence Number (i),
[ IPv4 (32) ],
[ IPv6 (128) ],
Port (16),
}
The Round field batches punching attempts; a new round immediately cancels all in-flight probes from the previous one, giving both sides a way to reprioritise without waiting for timeouts. Concurrency is capped by the server's advertised limit, communicated via a new transport parameter (nat_traversal, codepoint 0x3d7e9f0bca12fea6) during the handshake.
REMOVE_ADDRESS server → client only. Invalidates a previously advertised candidate, e.g., when a network interface goes down.
Why this is architecturally different from ICE
Traditional ICE requires a signalling channel entirely separate from the media/data transport SDP offer/answer, typically over a WebSocket or SIP channel, coordinating STUN checks that happen independently.
This draft collapses signalling and traversal into the same QUIC connection. If the two nodes already have a proxied QUIC connection to the relay (e.g., via CONNECT-UDP-LISTEN), they can start exchanging application data over the relay immediately, then upgrade to a direct path via connection migration once a punched route is validated, with the application never seeing an interruption.
There's also a real security tradeoff called out explicitly in the draft: extending path validation to the server side means a malicious client can direct the server to send validation traffic toward a third-party target IP, structurally similar to the amplification risk that connection-establishment address validation was designed to prevent. The draft's answer is rate limiting on unverified paths, though the amplification mitigation section is still marked as a TODO in the current revision.
What the Measurement Data Actually Shows
The IETF draft is a mechanism proposal. A 2024 paper, "Implementing NAT Hole Punching with QUIC" (Liang, Xu, Wang, Yang, Zhang), independently measured whether the theoretical advantage holds up.
Experimental setup
Two Docker-simulated LANs, each behind a NAT enforcing endpoint-independent mapping via iptables SNAT rules, are connected through a relay server. The team used Linux netem and Traffic Control (TC) to inject controlled RTT (20/100/200ms) and packet loss (0%/1%/1.5%/2%) across 12 combinations, running 100 trials each.
The RTT math
Ideal-case hole-punching time, derived analytically:
- QUIC: 1 RTT (address exchange via relay) + 1 RTT (QUIC handshake) = 2 RTTs
- TCP: 1 RTT (address exchange) + 1.5 RTTs (TCP three-way handshake) = 2.5 RTTs
Accounting for the race condition where one side's first punch packet arrives at the peer's NAT before the peer's own punch packet creates the matching session entry (forcing a discard-and-retry), the realistic range extends to:
- QUIC: 2–2.5 RTTs
- TCP: 2.5–3 RTTs
Measured results (0% packet loss)
| RTT | QUIC (measured) | TCP (measured) |
|---|---|---|
| 20ms | ~55ms | ~56ms |
| 100ms | ~213ms | ~256ms |
| 200ms | ~416ms | ~505ms |
At low RTT, QUIC's advantage is marginal; the paper attributes this to QUIC's user-space processing overhead eating into its theoretical edge. At higher RTT, the gap widens meaningfully: QUIC completes roughly 15–18% faster than TCP as latency increases.
The packet loss finding that matters more
Under 1% packet loss, the gap becomes dramatic not because of RTT math, but because of retransmission timeout design:
- QUIC's retransmission timer (per
draft-ietf-quic-recovery): fixed at 200ms - TCP's RTO (per RFC 6298... the paper cites RFC 6289, likely a typo for 6298): minimum 1 second, calculated from smoothed RTT and variance
At the RTT/loss levels tested (max 200ms RTT), TCP's RTO floor of 1 second dominates a single lost packet during hole punching costs TCP roughly 5x what it costs QUIC. This is the paper's most citable finding: QUIC's advantage in hole punching isn't primarily about handshake efficiency; it's about how much cheaper packet loss is during the punch itself.
Bandwidth, across unlimited/10Gbps/100Mbps/1Mbps conditions, showed negligible effect on hole punching time, unsurprising, since punching exchanges small control packets, not bulk data.
Connection Migration as a Recovery Mechanism
The second half of the measurement paper addresses a problem the IETF draft doesn't fully resolve: what happens when a successfully punched connection breaks a client switches from WiFi to cellular, a NAT session times out, a device roams to a new network.
Why you can't just reconnect directly
If A's address changes, A cannot simply send a connection migration probe directly to B. NAT-B has no session entry for A's new address, so the probe gets dropped. The relay server has to be involved again to re-establish the path, but how it's involved determines the cost.
Option 1: QUIC connection migration
1. A → S: "My address changed; tell B, and ask B to send me data"
2. S → B: forwards A's new public address
3. B → A: sends data (dropped by NAT-A, no session entry yet,
but this creates a NAT-B session entry for B→A traffic)
4. A → B: sends a connection migration request
(now passes through NAT-B, since step 3 created the matching entry)
Total: T_migrate = T(A→S) + T(S→B) + T(B→A) + T(A→B)
Because this reuses the existing QUIC connection's Connection ID, QUIC identifies a connection by CID, not by the IP: port 5-tuple, no new handshake, no new TLS negotiation. The connection ID persists across the address change, and PATH_CHALLENGE/PATH_RESPONSE frames confirm the new path is live before the application-layer connection formally migrates to it.
Option 2: Re-punching from scratch (QUIC or TCP)
1. A ↔ S: re-establish an entirely new QUIC/TCP connection
2. A → S: request S forward A's new address to B, ask B to send data
3. S → B: forwards A's address
4. B → A: sends data (same drop-then-record pattern as above)
5. A → B: new connection request (passes through via the fresh NAT-B entry)
6. Connection established, data flows
Total: T_re-punch = T(A↔S handshake) + T(A→S) + T(S→B) + T(B→A) + T(A→B handshake) + T(A→B)
The delta
Δt = T_re-punching - T_migrate = T(A↔S handshake) + T(A→B handshake)
The paper's result: connection migration saves 2 RTTs versus QUIC re-punching and 3 RTTs versus TCP re-punching because it eliminates both the A-S and A-B handshake steps entirely. The connection literally never tore down at the protocol level; only the underlying path changed.
For anything running on mobile networks where WiFi-to-cellular handoffs are routine, not exceptional, this is the difference between a visible reconnection stall and a seamless path switch that the application layer never observes.
What This Means Architecturally
For relay-based P2P tools: if your relay-and-punch architecture is built on TCP today, the migration path to QUIC isn't just "swap the transport"; it changes what's possible. Port multiplexing code disappears. TLS setup collapses into the transport handshake. And connection migration gives you a recovery primitive that TCP structurally cannot offer without an application-layer reconnection protocol bolted on top.
For observability tooling watching this traffic: QUIC's traversal packets look different at the kernel/network level than TCP's. Path validation probes on previously unseen 5-tuples, PATH_CHALLENGE/PATH_RESPONSE exchanges, and Connection-ID-based flow correlation (rather than 5-tuple correlation) all mean that eBPF-based flow tracking built assuming TCP semantics will misattribute or fragment what is, at the QUIC layer, a single continuous connection surviving multiple address changes.
The honest caveat: none of this works if you're behind a symmetric (address/port-dependent) NAT which the measurement paper explicitly excludes from its test environment because "testing revealed NAT devices in our current real network follow this rule," i.e., symmetric NAT is what they actually encountered, and they had to force endpoint-independent mapping via iptables to get clean results. That's worth sitting with: the protocol-level advantages of QUIC hole punching are real, but they don't solve the hardest traversal cases; those still fall back to relay (TURN-equivalent) traffic regardless of transport.
Current Status
The draft (draft-seemann-quic-nat-traversal-01) expired in April 2024 per the IETF's standard six-month draft lifecycle, which is normal for active work-in-progress documents. It doesn't mean the effort was abandoned, but it does mean this is pre-standardisation, not an RFC. The IANA considerations and amplification-attack mitigation sections are explicitly marked incomplete in the current text. Implementations exist in research/prototype form (the measurement paper builds directly on quic-go), but there is no ratified RFC number yet, and production libraries implementing this exact frame set are not widely deployed as of this writing.
The libp2p ecosystem, where Seemann's co-author on the related DCUtR (Direct Connection Upgrade through Relay) work also operates, is the most likely first production consumer of this mechanism, given the direct lineage from libp2p's existing hole-punching service.
Key Takeaways
QUIC's advantage in hole punching is real but modest at the handshake level 2 RTTs versus TCP's 2.5, narrowing further once you account for user-space processing overhead.
The bigger win is under packet loss, where QUIC's 200ms retransmission timer beats TCP's 1-second RTO floor by roughly 5x and hole punching, happening over lossy, unestablished paths, is exactly the scenario where this matters most.
Connection migration is the more significant architectural advantage 2-3 RTT savings on recovery, and more importantly, a recovery model that doesn't require tearing down and re-establishing application state.
Symmetric NAT is still the hard problem that this doesn't solve. No transport-level trick changes the fundamental traversal math when NAT mappings are destination-dependent.
This is pre-standard. Useful to understand and prototype against, not yet something to build production reliability guarantees on top of.
Further Reading
Primary sources:
-
draft-seemann-quic-nat-traversal-01(IETF, October 2023) - "Implementing NAT Hole Punching with QUIC" — Liang, Xu, Wang, Yang, Zhang (arXiv:2408.01791, 2024)
- "Decentralized Hole Punching" — Seemann, Inden, Vyzovitis (ICDCSW 2022)
- RFC 9000 (QUIC Transport), RFC 8445 (ICE), RFC 8838 (Trickle ICE)
Related:
-
draft-ietf-quic-multipath— using multiple validated paths simultaneously rather than migrating between them -
draft-ietf-masque-connect-udp-listen— the proxying mechanism this draft assumes for the relay phase
Discussion:
If you're running relay infrastructure today, is QUIC migration worth the rewrite over TCP re-punching?
Anyone tested this against symmetric NAT in the wild?
Top comments (0)