Pp2pbuilders
learn › The P2P Engineer's Field Manual

10 min read · also at #/learn/fm-day3

Day 03: UDP, TCP/IP & NAT Traversal

How two machines behind home routers manage to talk directly — the hardest, most-tested topic on the list.

⏱ ~2 hrs · theory 60 / lab 60

This is the chapter most likely to expose gaps in an interview, because it's where networking theory meets ugly
real-world reality. The internet was designed for every machine to have a public address; instead, billions of
devices hide behind NAT (Network Address Translation). P2P only works if peers can punch through
that. By the end of today you will have two UDP "peers" establishing a direct connection through a simulated
rendezvous server — the exact dance hyperdht performs.

3.1 TCP vs UDP — pick your tradeoff

TCPUDP
ConnectionHandshake, statefulConnectionless, fire-and-forget
DeliveryReliable, ordered, retransmittedBest-effort; may drop/reorder/dup
Flow/congestion controlBuilt inYou build it (or don't)
Head-of-line blockingYes — one lost segment stalls the streamNo — packets are independent
Overhead20-byte header, ACK traffic8-byte header
NAT traversalHard (stateful, sequence numbers)Easier (stateless datagrams)

P2P systems lean heavily on UDP for two reasons. First, NAT hole-punching is far more reliable
with stateless datagrams than with TCP's handshake-and-sequence machinery. Second, head-of-line blocking: TCP
guarantees order, so a single lost packet stalls everything behind it — fatal for real-time or multiplexed
streams. This is exactly why QUIC (and HTTP/3) was built on UDP. The tradeoff is that you must implement whatever
reliability you actually need yourself, on top of UDP.

The mental model
TCP is a phone call: connection established, ordered conversation, you notice if the line drops. UDP is postcards:
cheap, independent, might arrive out of order or not at all — but you can drop one in any mailbox without setup.

3.2 What NAT actually does

Your router owns one public IP. Every device behind it has a private IP (192.168.x.x). When an inside
device sends a packet out, the router rewrites the source (private IP : port) to
(public IP : some external port) and records the mapping in a translation table. Replies to that
external port get rewritten back and forwarded inside. The problem for P2P: an unsolicited inbound packet
from a stranger has no table entry, so the router drops it. Two peers behind NATs each expect the other to "call
first" — a deadlock.

The four NAT behaviors

TypeMapping rulePunchable?
Full-coneOne external port per internal socket; anyone can use itEasy
Restricted-coneSame port, but only hosts you've sent to may replyYes
Port-restrictedOnly the exact host:port you contacted may replyYes (most common case)
SymmetricA new external port per destination — mapping is unpredictableHard / often needs relay

The killer is symmetric NAT: because it allocates a different external port for every
destination, the port a peer learns via the rendezvous server is not the port that will be used to reach the
other peer. Hole-punching relies on predicting the external mapping, and symmetric NAT breaks that prediction. This
is the single most important NAT fact to be able to state crisply.

3.3 UDP hole punching, step by step

Both peers can make outbound connections; neither can receive unsolicited inbound. The trick is
to use a mutually reachable rendezvous (signaling) server to learn each other's public mappings,
then both fire packets at each other simultaneously. Each outbound packet opens a hole in its own NAT; when
the other peer's packet arrives, a matching table entry already exists, so it's allowed through.

  1. Peer A and Peer B each send a packet to the rendezvous server. The server observes their public (IP:port) mappings (this is what STUN does).
  2. The server tells A about B's public mapping, and B about A's.
  3. A and B both start sending UDP packets directly to each other's public mapping. The first packets may be dropped (no hole yet), but each one punches an outbound hole.
  4. Once both holes exist, packets flow directly — the server is no longer involved.

STUN, TURN, ICE — the standard stack

Holepunch note
hyperdht rolls its own version of this. The DHT itself is the rendezvous layer: peers announce
on a topic, the DHT coordinates the simultaneous-open, and a technique sometimes called "UDX" provides a
reliable-stream abstraction over UDP afterward. When you connect via hyperswarm, this whole sequence
happens under the hood. Being able to say "the DHT replaces the STUN/signaling server" is a strong interview answer.

3.4 Lab — simulate UDP hole punching locally


Goal

Build a rendezvous server and two peers using Node's dgram (UDP) module. The server introduces the
peers by their observed addresses; the peers then exchange packets directly. Running on localhost there's
no real NAT, but the protocol logic — observe address, exchange, simultaneous send — is identical to the
real thing.

Step 1 — the rendezvous server

// rendezvous.js — learns each peer's public address and introduces them
const dgram = require('dgram');
const server = dgram.createSocket('udp4');
const peers = {};

server.on('message', (msg, rinfo) => {
  const { name } = JSON.parse(msg);
  // rinfo is the address the SERVER sees — i.e. the public mapping (STUN)
  peers[name] = { address: rinfo.address, port: rinfo.port };
  console.log(`registered ${name} @ ${rinfo.address}:${rinfo.port}`);

  // once both peers are known, introduce them to each other
  const names = Object.keys(peers);
  if (names.length === 2) {
    const [a, b] = names;
    introduce(a, b); introduce(b, a);
  }
});

function introduce(to, about) {
  const payload = Buffer.from(JSON.stringify({ peer: about, addr: peers[about] }));
  server.send(payload, peers[to].port, peers[to].address);
}

server.bind(9000, () => console.log('rendezvous on :9000'));

Step 2 — a peer

// peer.js — usage: node peer.js <myName>
const dgram = require('dgram');
const myName = process.argv[2] || 'peerA';
const sock = dgram.createSocket('udp4');

const RDV = { port: 9000, host: '127.0.0.1' };
let partner = null;
let connected = false;

sock.on('message', (msg, rinfo) => {
  let data; try { data = JSON.parse(msg); } catch { data = { raw: msg.toString() }; }

  // (a) introduction from the rendezvous server
  if (data.addr) {
    partner = data.addr;
    console.log(`got partner ${data.peer} @ ${partner.address}:${partner.port}`);
    startPunching();
    return;
  }
  // (b) a direct packet from the partner — the hole is open!
  if (data.hello) {
    if (!connected) {
      connected = true;
      console.log(`✅ DIRECT connection established with ${data.hello}`);
    }
    return;
  }
});

// punch: fire packets straight at the partner's public mapping repeatedly
function startPunching() {
  const timer = setInterval(() => {
    if (connected) return clearInterval(timer);
    const pkt = Buffer.from(JSON.stringify({ hello: myName }));
    sock.send(pkt, partner.port, partner.address); // opens our hole + may pass theirs
  }, 200);
}

// register with the rendezvous server on startup
sock.send(Buffer.from(JSON.stringify({ name: myName })), RDV.port, RDV.host);
console.log(`${myName} registering…`);

Run it (three terminals)

# terminal 1
node rendezvous.js
# terminal 2
node peer.js peerA
# terminal 3
node peer.js peerB
# peerA output:
# peerA registering…
# got partner peerB @ 127.0.0.1:53412
# ✅ DIRECT connection established with peerB

What just happened (and what's different on real NAT)

3.5 Self-check & interview drill

« Day 2: BitTorrent & Hypercore Day 4: Binary Protocols & Serialization »