UDP Hole Punching: How Two Machines Behind NAT Talk Directly
Working through NAT traversal and bulk transfer over an unreliable datagram protocol — rendezvous, simultaneous open, keepalives, chunking, integrity and flow control — by building a peer-to-peer file sharing system in Go.
Two laptops on opposite sides of the world, each behind a home router. Neither has a public IP. Neither owner will configure port forwarding. They want to send each other a file directly, without a server in the middle paying for the bandwidth.
My instinct said that was impossible. Every packet aimed at either machine arrives at a router with no idea which internal device it belongs to, and gets dropped. Yet this is exactly how Zoom calls, BitTorrent swarms and multiplayer game sessions work. The technique is UDP hole punching, and it exploits a specific property of how NAT devices decide what to let in.
I wanted to understand two things properly: how a direct path gets opened between two unaddressable hosts, and what you have to rebuild yourself once you’re moving bulk data over a protocol that guarantees nothing. So I built a peer-to-peer chat and file sharing system in Go and worked through both: github.com/rustamniraula90/gop2p.
These are my notes from that build.
Concept 1: NAT is a phone system with no direct extensions
A router doing Network Address Translation has one public address and many internal devices. When an internal device sends a packet out, the router picks an unused public port, rewrites the source address, and records the pairing:
internal 192.168.1.14:3001 ⇄ public 203.0.113.7:51820
Return traffic hitting 203.0.113.7:51820 gets rewritten back and delivered. Traffic arriving at any other port has no entry to match and is discarded.
Three properties follow, and they turn out to be the whole basis of hole punching:
- Mappings are created by outbound packets. Nothing else creates them. You cannot ask for one.
- Mappings are usually destination-filtered. Most consumer routers only accept return packets from the address the outbound packet went to. (This is “port-restricted cone” NAT — it matters later.)
- Mappings expire. UDP has no connection state, so the router guesses. Idle entries can be evicted in as little as 30 seconds.
So neither peer can be called. But both can dial, and a mapping created by dialing will accept the reply. If both dial each other at the same moment, each one’s outbound packet opens the hole the other’s packet needs. That simultaneity is the trick, and arranging it is the entire problem.
Concept 2: A rendezvous server that learns your address for you
Peer A cannot know its own public address — the NAT that assigned it sits outside A’s view. But any server A talks to can see it, because translation already happened by the time the packet arrives. So the first job of a coordination server is to be a mirror.
This is why I have the server read the address off the socket rather than trust anything the client claims:
n, remote, err := conn.ReadFromUDP(buf)
// ...
go handler.HandlePacket(remote, buf[:n])
func (r Registry) Register(id string, name string, addr *net.UDPAddr) {
r.clients[id] = &ClientInfo{
ID: id, Name: name,
IP: addr.IP, // the post-NAT address, observed
Port: addr.Port,
LastSeen: time.Now(),
}
}
Once I’d written that, STUN made sense to me for the first time: it’s built on the same principle — the observer tells you who you appear to be. The server here isn’t a relay for data, just a directory of “where each peer appears to live,” assembled from packets it received.
Keepalives exist to fight timeouts, not to detect death
Because mappings expire on idle, a peer that goes quiet becomes unreachable even though it’s still running. So the client re-announces itself on a timer:
ticker := time.NewTicker(10 * time.Second)
for range ticker.C {
pm.sendHeartbeat()
}
Ten seconds against a 30-second eviction window. What I found interesting is the inversion from what “heartbeat” normally means: this isn’t the client proving liveness to the server, it’s the client keeping its own NAT hole propped open. That’s why the server handles register and heartbeat with the same function — a heartbeat is a re-register that skips the acknowledgement.
Concept 3: Address exchange as a consent step
Building the directory made a privacy consequence obvious that I hadn’t considered up front: listing peers means publishing where people live. So I split discovery from address exchange. Browsing the directory returns identities with addresses deliberately withheld:
peers = append(peers, protocol.PeerInfoPayload{
ID: c.ID,
Name: c.Name,
IP: nil, // withheld until both sides consent
Port: 0,
})
Addresses only go out after both parties agree to connect, which makes the handshake a four-message consent flow through the server:
A Server B
|-- CONNECT_REQUEST(B) ------->| |
| |-- CONNECT_FORWARD(A) ------->|
| |<------- CONNECT_ACCEPT(A) ---|
|<-- PEER_INFO(B: ip:port) ----|--- PEER_INFO(A: ip:port) -->|
| |
|============= PUNCH / PUNCH_ACK (direct) ==================>|
|<========= the server is now out of the picture ============|
The last server step is the load-bearing one: both PEER_INFO messages go out from the same handler, microseconds apart. That’s not tidiness, it’s the mechanism. Hole punching needs both sides transmitting at nearly the same instant, and the only shared clock they have is “when the server told me.”
Concept 4: Simultaneous open, and why the first packets are supposed to fail
Once each side knows where to aim, both fire. The first packets die, and that’s the design working as intended:
- A’s punch reaches B’s router. B hasn’t sent anything to A yet, so there’s no mapping for A. Dropped.
- But A’s punch did create a mapping on A’s side, valid for B’s address.
- B’s punch, moments later, reaches A’s router and finds that hole waiting. It gets through.
- A replies. B’s router now has its own mapping from B’s punch, so the reply lands too.
A dropped packet isn’t a failure — it’s the cost of opening your own side. Which means the strategy is to retry on an interval until confirmation arrives:
func (pm *P2PManager) startPunching(peer *Peer) {
addr := &net.UDPAddr{IP: peer.IP, Port: peer.Port}
// send packet and pray
for i := 0; i < 10; i++ {
if pm.Peers[peer.ID].State == StateConnected {
return
}
pm.udp.Send(addr, protocol.UDPMessage{
Type: protocol.TypePunch,
Payload: map[string]string{"from": pm.identity.ID},
})
time.Sleep(500 * time.Millisecond)
}
}
Ten attempts over five seconds converts a race into a near-certainty. The // send packet and pray comment is the one I wrote while figuring this out, and I kept it because it describes the mechanism accurately: you can’t verify the hole is open except by something coming back through it.
And when something does come back, I take the address from the packet rather than the directory:
peer.State = StateConnected
peer.IP = remote.IP // trust the socket, not the directory
peer.Port = remote.Port
If the NAT reassigned a port between registration and the punch, the server’s record is stale and the source address on the wire is ground truth. Same principle as the server observing remote in the first place, one layer down.
Where hole punching stops working
Property 2 said most routers filter by destination address. Symmetric NAT goes further: it allocates a different public port for every distinct destination. The port the server observed is therefore not the port your peer’s traffic will use, so your peer aims at a hole that will never exist. Retrying can’t fix that.
This is where production systems pair STUN with TURN — a relay that carries traffic when punching fails. Falling back costs you the bandwidth savings that are the whole point of P2P, so it’s a last resort rather than a default. My implementation punches, and if punching fails the peer stays disconnected; understanding why it fails was the goal, and a relay would have hidden that.
Concept 5: UDP gives you a datagram, and nothing else
Suppose the hole is open. I now had a channel that can lose packets, reorder them, duplicate them and corrupt them, with no notification of any of it. Moving a file across that meant rebuilding, by hand, the guarantees TCP would have provided:
| Guarantee | Who provides it | Mechanism |
|---|---|---|
| Message boundaries | you | framing, or one message per datagram |
| Integrity | you | checksum/hash per unit |
| Ordering | you | explicit sequence numbers |
| Completeness | you | track what’s missing, re-request |
| Flow control | you | limit outstanding requests |
Writing that table out is what turned “UDP is unreliable” from a slogan into a checklist. The transfer became a request/response protocol over indexed pieces:
Downloader Uploader
|--- DOWNLOAD_REQUEST(file, reqID) --------->| compress + hash
|<-- DOWNLOAD_INFO(chunkCount, sizes) -------|
|--- CHUNK_REQUEST(reqID, 0) --------------->|
|<-- CHUNK_RESPONSE(0, data, sha256) --------|
| ... repeat ... |
Chunking: sequence numbers are what make loss recoverable
Splitting the payload into fixed-size indexed chunks buys you everything else. An index means you can detect a gap, re-request one piece without restarting, and reassemble out-of-order arrivals. Negotiating up front tells the receiver how many pieces exist, so it can allocate its whole table before the first byte arrives:
chunkCount := int(upload.CompressedSize) / upload.ChunkSize
if int(upload.CompressedSize)%upload.ChunkSize != 0 {
chunkCount++
}
I compress before chunking rather than per chunk, for two reasons: the compressor sees the whole file’s redundancy instead of a 16 KB window, and the piece count is known in advance.
Integrity: verify each piece before trusting it
Corruption is silent, so each chunk carries a hash of its own contents, computed by the sender during preparation, and the receiver refuses to store anything that doesn’t match:
hash := sha256.Sum256(data)
computedSHA := hex.EncodeToString(hash[:])
if computedSHA != sha {
download.Chunks[index].Status = ChunkError
download.Chunks[index].RetryCount++
return fmt.Errorf("chunk SHA mismatch")
}
Verified chunks land as individual files named by index, which is what makes ordering a non-problem: chunk_37 is correct whenever it arrives, and order only matters at assembly, where pieces are concatenated by index and decompressed.
There’s a second integrity check hiding in that design, and I only noticed it afterwards. Gzip has its own internal structure, so if the pieces were assembled in the wrong order, decompression fails rather than silently producing a corrupt file. Layering a format that detects its own corruption on top of per-chunk hashes gave me end-to-end validation I hadn’t written.
Flow control: a window, because both extremes are wrong
One request at a time wastes a full round-trip per chunk — on a 100 ms link that’s 160 KB/s regardless of how fat the pipe is. All requests at once overruns buffers and drops most of them. So I keep a bounded number in flight:
windowSize := 8
for i := 0; i < windowSize; i++ {
pm.requestNextChunk(requestID)
time.Sleep(time.Millisecond * 10)
}
and refill on each arrival, which holds the window full for the rest of the transfer:
if err := pm.Downloader.SaveChunk(resp.RequestID, resp.ChunkIndex,
resp.ChunkData, resp.ChunkSHA); err != nil {
return
}
pm.requestNextChunk(resp.RequestID)
This is a receiver-driven window, the inverse of TCP’s sender-driven one, and I like it for peer-to-peer: the side that knows its own downlink capacity sets the rate, and a peer serving five downloads doesn’t have to reason about any of them.
Design notes for the next iteration
The table listed five guarantees. This version implements framing, integrity, ordering and flow control; the notes below are where I stopped, and why each one clarified something.
Retransmission is out of scope in this pass. A chunk marked ChunkRequesting stays there — nothing re-arms it, so a lost datagram narrows the window by one. I built the state machine a timer would drive (RetryCount is in the struct, ready for it) before building the timer, and doing it in that order is what made the role of timers concrete: reliability over UDP is fundamentally about deciding how long you’re willing to wait, because you can’t otherwise distinguish “lost” from “slow.” Adding a per-chunk deadline that returns ChunkRequesting to ChunkPending is the natural next step.
Frame size is a fragmentation trade-off. ChunkData []byte inside a JSON message is base64-encoded, so a 16 KB chunk is ~22 KB on the wire — past a 1500-byte MTU, so each chunk is IP-fragmented into ~15 packets, and losing any one fragment discards the chunk. I chose 16 KB for fewer round-trips; measured against fragmentation, a binary frame under ~1400 bytes trades round-trips for a smaller blast radius per loss, and pairs better with retransmission. Worth measuring both.
The relay’s registry is single-process simple. Each packet is handled in its own goroutine and they share one map[string]*ClientInfo. That’s fine for the two-client scenario I was testing; a sync.RWMutex (or moving registration onto a single owning goroutine) is what makes it safe under real concurrency. LastSeen is recorded and not yet consumed — it’s the hook for evicting stale peers.
Request and response share a type tag. TypeListPeersRequest and TypeListPeersResponse are both 0x04, disambiguated by direction: the server never receives a response and the client never receives a request. It’s compact and works for a two-role topology. Distinct tags become worthwhile as soon as a node can play both roles.
What I took away
The thing I actually wanted — two clients behind separate NATs, no port forwarding, no relay carrying their data, finding each other through a coordinator that only ever tells them where to aim — works, and files arrive byte-for-byte intact.
What I didn’t expect was how much I’d learn about TCP by not having it. Sequence numbers, acknowledgements, retransmission timers, MSS negotiation: I’d always filed those as protocol trivia. Building a transfer that needs each one, and feeling exactly which problem each solves, made TCP look less like a spec to memorise and more like a list of hard-won answers.