A 7-Day Engineering Crash Course

The P2P Engineer's
Field Manual Distributed networking, from Kademlia to production profiling

Seven focused chapters — one per day, ninety minutes each — taking you from DHT theory to hand-written binary protocols, NAT traversal, Noise handshakes, and the profiler flame graphs that prove your optimizations actually worked. Every chapter ends in a runnable lab.

Format 7 chapters · 1–2 hrs each Stack Node.js · Holepunch ecosystem Prereq working JavaScript
Day 01 — Foundations

Distributed Hash Tables & Kademlia

The XOR metric that quietly organizes every modern swarm.

⏱ ~90 min · theory 45 / lab 45

Almost everything else in this manual sits on top of one idea: a distributed hash table. A DHT lets thousands of machines, none of which trust or even know each other, collectively behave like one giant key–value store — with no server, no central index, and no single point of failure. When you ask the network "who has the data for key K?", the lookup converges on the answer in roughly log₂(N) hops even across millions of nodes. Kademlia is the design that made this practical, and it is the ancestor of the BitTorrent DHT, IPFS, Ethereum's discovery layer, and Holepunch's hyperdht.

1.1 The problem a DHT solves

Imagine you want to store a billion key–value pairs across a fluctuating set of peers. A naive approach puts everything on one coordinator — but that coordinator is a bottleneck and a kill switch. The DHT insight is to give every node and every key an ID drawn from the same address space (say, 256-bit numbers), then define a rule: a key lives on the node(s) whose ID is "closest" to the key's ID. Now any node can independently figure out where a key should be, without asking a server.

Two questions fall out of that: (1) what does "closest" mean, and (2) how does a node that knows only a handful of peers route a query to the right corner of the network? Kademlia answers both elegantly.

1.2 XOR as a distance metric

Kademlia measures distance between two IDs as their bitwise XOR, interpreted as an integer: distance(a,b) = a XOR b. This looks strange the first time you see it, but it has exactly the properties a metric needs:

The magic is that XOR distance is prefix-sensitive: two IDs that share a long leading bit-prefix are numerically close. So "getting closer to the target" is the same as "matching more of its high bits," and that turns routing into a binary-search-like descent through the ID space.

Intuition Think of IDs as leaves of a binary tree. Each step of a lookup lets you correct one more bit, halving the remaining search space. Halving repeatedly across N nodes is why lookups take O(log N) hops.

1.3 k-buckets: the routing table

A node can't remember every peer, so Kademlia keeps a structured, partial view. For each bit-distance band i (peers whose ID differs from mine starting at bit i), the node keeps a k-bucket: a list of up to k contacts (commonly k = 20). The result is a routing table that is dense for nearby IDs and sparse for far ones — you know your immediate neighborhood in fine detail and the rest of the universe only coarsely. That asymmetry is exactly what you need: to route, you only ever need a peer that is closer than you to the target, and the table guarantees you have one.

k-buckets also encode a liveness policy. When a bucket is full and a new node appears, Kademlia pings the least-recently-seen contact; if it still answers, the newcomer is discarded. This deliberately favors long-lived nodes, because empirically a node that has been up for an hour is likely to stay up another hour. It also makes the table naturally resistant to flooding attacks.

1.4 The four RPCs

RPCPurpose
PINGLiveness check; the heartbeat behind bucket maintenance.
STORETell a node to hold a (key, value) pair.
FIND_NODE"Give me the k closest contacts you know to this ID." The workhorse of routing.
FIND_VALUELike FIND_NODE, but if the node holds the value it returns it directly.

1.5 Iterative lookup and the α parameter

To find a target, a node starts with the closest contacts it knows and sends them FIND_NODE in parallel. Each response returns contacts that are (hopefully) closer; the node keeps the best results it has seen, picks the closest un-queried ones, and repeats. Lookups are iterative — the searcher drives every step and stays in control — rather than recursive, which improves resilience to misbehaving nodes.

The concurrency factor α (typically 3) controls how many queries are in flight at once. It trades latency against redundant traffic: higher α tolerates slow or dead peers without stalling the descent, at the cost of extra packets.

Holepunch note hyperdht is Kademlia-derived but tuned for a different job: instead of storing arbitrary blobs, its primary role is peer discovery and connection bootstrapping. It stores small mutable/immutable records (signed announcements of "I am reachable at this address") and integrates UDP hole-punching directly into the lookup. Same routing skeleton, different payload. Keep this contrast ready — it is a likely interview question.

1.6 Lab — build a minimal Kademlia routing core

Lab 01

Goal

Implement XOR distance, a k-bucket routing table, and a simulated iterative lookup over an in-memory network of nodes. You will see the log N convergence with your own eyes by printing each hop.

Setup

# no dependencies — pure Node
mkdir kad-lab && cd kad-lab
node --version   # v18+ recommended

Step 1 — IDs and XOR distance

We use 16-bit IDs (a Buffer of 2 bytes) so the numbers stay small and printable. Real systems use 160 or 256 bits, but the math is identical.

// kad.js
const ID_BITS = 16;
const ID_BYTES = ID_BITS / 8;

function randomId() {
  const b = Buffer.allocUnsafe(ID_BYTES);
  for (let i = 0; i < ID_BYTES; i++) b[i] = (Math.random() * 256) | 0;
  return b;
}

// XOR distance as a BigInt so it is directly comparable
function distance(a, b) {
  let d = 0n;
  for (let i = 0; i < ID_BYTES; i++) d = (d << 8n) | BigInt(a[i] ^ b[i]);
  return d;
}

// index of the most-significant differing bit -> which bucket a peer belongs in
function bucketIndex(self, other) {
  for (let i = 0; i < ID_BYTES; i++) {
    const x = self[i] ^ other[i];
    if (x !== 0) return i * 8 + Math.clz32(x) - 24; // clz32 on a byte
  }
  return ID_BITS; // identical
}

Step 2 — the k-bucket routing table

const K = 4; // small k so buckets fill up visibly

class RoutingTable {
  constructor(selfId) {
    this.self = selfId;
    this.buckets = Array.from({ length: ID_BITS + 1 }, () => []);
  }
  add(node) {
    if (node.id.equals(this.self)) return;
    const b = bucketIndex(this.self, node.id);
    const bucket = this.buckets[b];
    if (bucket.find(n => n.id.equals(node.id))) return;
    if (bucket.length < K) bucket.push(node);
    // (real impl: ping least-recently-seen before evicting)
  }
  // return the n contacts closest to a target id
  closest(target, n = K) {
    return this.buckets.flat()
      .sort((x, y) => (distance(x.id, target) < distance(y.id, target) ? -1 : 1))
      .slice(0, n);
  }
}

Step 3 — a simulated network + iterative lookup

const ALPHA = 3;

class Node {
  constructor(id) { this.id = id; this.table = new RoutingTable(id); }
  // the FIND_NODE RPC: just answer with my closest known contacts
  findNode(target) { return this.table.closest(target); }
}

function buildNetwork(count) {
  const nodes = Array.from({ length: count }, () => new Node(randomId()));
  // seed each node's table with random acquaintances (bootstrap gossip)
  for (const n of nodes)
    for (let i = 0; i < 20; i++)
      n.table.add(nodes[(Math.random() * count) | 0]);
  return nodes;
}

// iterative lookup driven by one starting node
function lookup(start, target) {
  let shortlist = start.table.closest(target, ALPHA);
  const queried = new Set();
  let hops = 0, best = distance(start.id, target);

  while (true) {
    const batch = shortlist.filter(n => !queried.has(n.id.toString('hex'))).slice(0, ALPHA);
    if (batch.length === 0) break;
    hops++;
    let found = [];
    for (const peer of batch) {
      queried.add(peer.id.toString('hex'));
      found = found.concat(peer.findNode(target));
    }
    // merge, dedupe, keep K closest overall
    const seen = new Map();
    for (const n of shortlist.concat(found)) seen.set(n.id.toString('hex'), n);
    shortlist = [...seen.values()]
      .sort((x, y) => (distance(x.id, target) < distance(y.id, target) ? -1 : 1))
      .slice(0, K);
    const closest = distance(shortlist[0].id, target);
    if (closest >= best) break; // no improvement -> converged
    best = closest;
  }
  return { hops, winner: shortlist[0], best };
}

// --- run it ---
const N = 5000;
const net = buildNetwork(N);
const target = randomId();
const start = net[0];
const r = lookup(start, target);
console.log(`network size  : ${N}`);
console.log(`target        : ${target.toString('hex')}`);
console.log(`hops to find  : ${r.hops}`);
console.log(`log2(N)       : ${Math.log2(N).toFixed(1)}`);
console.log(`closest found : ${r.winner.id.toString('hex')}`);

Run & expected output

node kad.js
// network size  : 5000
// target        : 9c41
// hops to find  : 4         <- compare to log2(5000) ≈ 12.3
// closest found : 9c2f      <- shares the 9c.. prefix

Experiments to try

  • Sweep N from 100 to 100000 and plot hops vs N. The curve is logarithmic — this is the headline property of every DHT.
  • Set ALPHA = 1 and re-run. Lookups still work but take more rounds and are fragile if a queried node is "dead." Then try ALPHA = 8 and watch redundant queries climb.
  • Reduce the bootstrap acquaintance count from 20 to 3. Lookups degrade or fail — the routing table is too sparse. This is why real DHTs run periodic bucket-refresh lookups.

1.7 Self-check & interview drill

Why XOR distance instead of, say, numeric subtraction?
XOR is symmetric (d(a,b)=d(b,a)) and unidirectional (one unique node at each distance), so all lookups for a key converge along the same path and routing tables mutually reinforce. Subtraction is not symmetric and breaks both properties.
What does the k in k-bucket buy you?
Redundancy and churn-resistance. Keeping k contacts per band means losing one peer doesn't blind you to that region of the ID space, and the "ping LRU before evicting" rule biases toward stable, long-lived nodes.
How does hyperdht differ from textbook Kademlia?
It uses the Kademlia routing skeleton for peer discovery rather than blob storage: nodes announce signed reachability records, and the DHT coordinates UDP hole-punching to bootstrap direct connections. Lookups return where to reach a peer, not arbitrary stored values.
Day 02 — Swarms & Logs

BitTorrent & Hypercore

Two ways to move data with no server: a static swarm and a signed, append-only log.

⏱ ~90 min · theory 50 / lab 40

Yesterday's DHT tells peers how to find each other. Today is about what they do once connected: move data efficiently and verifiably. BitTorrent solved this for static files in 2001 with a beautifully pragmatic incentive design. Hypercore — the core of the Holepunch stack — solved a harder version: a single-writer, append-only log that any peer can replicate and cryptographically verify block-by-block. Understanding the contrast between them is the fastest way to sound fluent about P2P data transfer.

2.1 BitTorrent: the swarm model

A torrent splits a file into fixed-size pieces (typically 256 KB–1 MB), and the .torrent metadata (or magnet link) carries a SHA-1/SHA-256 hash of every piece. That hash list is the trust anchor: a peer can verify any piece it receives against the expected hash and reject corrupt or malicious data without trusting the sender. Peers swap pieces until everyone has the whole file.

Piece selection: rarest-first

If every peer downloaded pieces in order, the first pieces would be everywhere and the last pieces would be scarce — and if the only seed left before sharing a rare piece, the file would be unrecoverable. BitTorrent instead prefers the rarest piece first: each peer tracks how many of its neighbors have each piece and fetches the least-replicated ones. This spreads scarcity risk and keeps the swarm healthy as seeds come and go. A small exception ("random first piece") bootstraps a brand-new peer quickly so it has something to trade.

Choking: tit-for-tat incentives

BitTorrent's real innovation is social, not technical. Each peer chokes (refuses to upload to) most of its neighbors and unchokes only a few — preferentially those who upload to it the fastest. This tit-for-tat reciprocation rewards contributors and starves freeloaders. Periodically a peer does an optimistic unchoke of a random neighbor, to discover better partners and to give newcomers (who have nothing to offer yet) a foot in the door.

MechanismWhat it prevents
Per-piece hashesCorrupt / malicious data acceptance
Rarest-firstPiece scarcity & unrecoverable swarms
Tit-for-tat chokingFreeloading (download without upload)
Optimistic unchokeNewcomer starvation & local optima
BitTorrent DHT (BEP 5) + PEXDependence on a central tracker

The bitfield message — sent on connection — is how a peer advertises which pieces it already has, as a compact bit array. have messages then incrementally announce new pieces. The BitTorrent DHT (a Kademlia network keyed by infohash) and PEX (peer exchange) let swarms operate with no tracker at all — this is exactly the DHT material from Day 1 applied in production.

2.2 Hypercore: the append-only log

BitTorrent distributes a fixed, known-in-advance file. But many applications need a growing data feed — a chat history, a database changelog, a file that gets revised. Hypercore is the answer: a secure, append-only log owned by a single keypair. Only the holder of the secret key can append; anyone with the public key (which is the feed's address) can read and verify.

The Merkle tree over blocks

Each appended block is hashed (BLAKE2b), and those hashes form a Merkle tree. The signed root commits to the entire log. The payoff: a peer can request block i alone and receive it plus a small set of sibling hashes (a Merkle proof) that verify it against the signed root — without downloading the rest of the log. This is what makes random access and partial replication trustworthy. It is the same idea as BitTorrent's piece hashes, generalized into a tree so proofs stay O(log n) and the structure can grow.

Why a tree, not a flat list A flat hash list (BitTorrent) is fine when the piece count is fixed and known. A Merkle tree lets the dataset grow without bound while keeping verification proofs logarithmic and letting the writer publish a single signed root that commits to all of history.

Single-writer, many-reader

The keypair is the heart of the model. The public key is the discovery key / address; the secret key authorizes appends. Replication is a duplex stream: two peers exchange what blocks they have and want, request ranges, and verify every block against the Merkle root as it arrives. Higher layers in the Holepunch stack (hyperbee for key–value/B-tree, hyperdrive for filesystems, autobase for multi-writer) are all built on this one primitive.

BitTorrentHypercore
Data shapeStatic file, fixed piecesAppend-only growing log
Trust anchorFlat list of piece hashesSigned Merkle root + keypair
WritersWhoever seeds (content-addressed)Exactly one (key-addressed)
MutabilityImmutableAppend (history preserved)
VerificationPer-piece hash checkPer-block Merkle proof

2.3 Lab — append-only log with Merkle verification

Lab 02

Goal

Build a tiny append-only log that produces a Merkle root and lets a "reader" verify a single block with a proof — the conceptual core of Hypercore — using only Node's built-in crypto. Then run real Hypercore replication between two instances.

Part A — Merkle log from scratch (no dependencies)

// merkle-log.js
const crypto = require('crypto');
const H = (buf) => crypto.createHash('sha256').update(buf).digest();

class MerkleLog {
  constructor() { this.blocks = []; this.leaves = []; }

  append(data) {
    const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
    this.blocks.push(buf);
    this.leaves.push(H(buf));
    return this.blocks.length - 1; // index
  }

  // build the tree bottom-up; return the layered hash arrays
  tree() {
    let layer = this.leaves.slice();
    const layers = [layer];
    while (layer.length > 1) {
      const next = [];
      for (let i = 0; i < layer.length; i += 2) {
        const left = layer[i];
        const right = layer[i + 1] || left; // duplicate last if odd
        next.push(H(Buffer.concat([left, right])));
      }
      layers.push(next);
      layer = next;
    }
    return layers;
  }

  root() { const l = this.tree(); return l[l.length - 1][0]; }

  // proof = sibling hash at each level needed to recompute the root
  proof(index) {
    const layers = this.tree();
    const path = [];
    let idx = index;
    for (let lvl = 0; lvl < layers.length - 1; lvl++) {
      const isRight = idx % 2 === 1;
      const sibling = isRight ? idx - 1 : idx + 1;
      const layer = layers[lvl];
      path.push({ hash: (layer[sibling] || layer[idx]), isRight });
      idx = idx >> 1;
    }
    return path;
  }
}

// a reader who has ONLY the trusted root verifies one block
function verify(block, index, proof, trustedRoot) {
  let h = H(block);
  for (const step of proof) {
    h = step.isRight
      ? H(Buffer.concat([step.hash, h]))   // our node was on the right
      : H(Buffer.concat([h, step.hash]));
  }
  return h.equals(trustedRoot);
}

// --- demo ---
const log = new MerkleLog();
['genesis', 'block-1', 'block-2', 'block-3', 'block-4'].forEach(d => log.append(d));
const root = log.root();

const i = 3;
const ok = verify(log.blocks[i], i, log.proof(i), root);
console.log('root      :', root.toString('hex').slice(0, 16), '…');
console.log('verify #3 :', ok);

// tamper test: flip the block, proof must fail
const bad = Buffer.from('block-3-EVIL');
console.log('tampered  :', verify(bad, i, log.proof(i), root));

Expected output

node merkle-log.js
// root      : 2f6a... …
// verify #3 : true
// tampered  : false   <- the Merkle proof rejects altered data

Part B — the real thing: Hypercore replication

Now see it in production form. Modern Hypercore (v11+) is managed through a Corestore backed by a storage path; we replicate a log from a writer store to a key-only reader store over piped streams (the same API works over a real socket).

npm init -y
npm install hypercore corestore
// hc.js
const Corestore = require('corestore');
const os = require('os'), path = require('path'), fs = require('fs');

async function main() {
  // two independent stores on disk (Hypercore 11+ requires a storage dir)
  const dirA = fs.mkdtempSync(path.join(os.tmpdir(), 'hc-a-'));
  const dirB = fs.mkdtempSync(path.join(os.tmpdir(), 'hc-b-'));

  // writer core
  const store = new Corestore(dirA);
  const writer = store.get({ name: 'log' });
  await writer.ready();
  await writer.append([Buffer.from('hello'), Buffer.from('p2p'), Buffer.from('world')]);
  console.log('writer length:', writer.length,
              'key:', writer.key.toString('hex').slice(0, 12));

  // reader store: opens the core by PUBLIC KEY only — it has no data yet
  const store2 = new Corestore(dirB);
  const reader = store2.get({ key: writer.key });
  await reader.ready();

  // pipe the two replication streams together (stand-in for a socket)
  const s1 = store.replicate(true);
  const s2 = store2.replicate(false);
  s1.pipe(s2).pipe(s1);

  // reader fetches block 2 — verified against the signed root automatically
  const block = await reader.get(2);
  console.log('reader got block 2:', block.toString()); // "world"
  process.exit(0);
}
main();

Experiments

  • In Part A, append 1000 blocks and confirm proof length grows like log₂(n), not n. That logarithmic proof size is the whole point.
  • In Part B, call reader.get(0) before the writer appends (start the reader first), then append — observe that get resolves once the block arrives. Hypercore get means "wait until available."
  • Inspect reader.length vs reader.contiguousLength to see the difference between "known to exist" and "actually downloaded."

2.4 Self-check & interview drill

Why rarest-first instead of sequential download?
It maximizes piece diversity in the swarm so the file stays recoverable when seeds leave, and it prevents the "last piece" bottleneck. Sequential only makes sense for streaming use cases.
Contrast BitTorrent's trust model with Hypercore's.
BitTorrent is content-addressed: trust comes from a fixed list of piece hashes for an immutable file, anyone can seed. Hypercore is key-addressed: a single keypair authorizes appends, readers verify each block against a signed Merkle root, and the log can grow over time.
What makes a Merkle tree better than a flat hash list here?
It supports an unbounded, growing dataset with O(log n) verification proofs and a single signed root committing to all history — exactly what an append-only log needs.
Day 03 — The Wire

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

Lab 03

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)

  • The server learned each peer's address as it observed it — that's the STUN principle. On real NAT this is the public mapping, not the private LAN address.
  • Both peers fired at each other simultaneously; the repeated sends model the "first packets may be dropped while the hole opens" reality.
  • Once connected, the server is never used again — true direct P2P.
  • On symmetric NAT, the port the server observed would not match the port used peer-to-peer, so the punch fails and you'd fall back to a TURN-style relay. Try simulating this by having a peer send its punch packets from a different socket than the one it registered with — the introduction address no longer matches and the connection never forms.

3.5 Self-check & interview drill

Walk me through what happens when two peers behind NATs connect.
Both contact a mutually reachable rendezvous/STUN server, which observes and reports each peer's public IP:port mapping. Both peers then send UDP packets directly to each other's mapping at the same time; each outbound packet opens a hole in its own NAT, so the incoming packet finds a matching table entry and passes. After that they talk directly. If both are behind symmetric NAT, punching fails and traffic falls back to a TURN relay.
Why does P2P prefer UDP over TCP?
Stateless datagrams make hole-punching far more reliable, and UDP avoids TCP head-of-line blocking where one lost packet stalls the whole stream. The cost is implementing your own reliability/ordering when you need it — which is what QUIC and Holepunch's UDX do.
Why specifically does symmetric NAT break hole punching?
It assigns a new, unpredictable external port per destination. The mapping the STUN server saw applies only to traffic toward the server, so the peer-to-peer port differs and can't be predicted — defeating the punch.
Day 04 — On the Wire, Compactly

Binary Protocols & Serialization

Why every serious P2P system hand-rolls its bytes — framing, varints, and zero-copy formats.

⏱ ~90 min · theory 45 / lab 45

JSON is wonderful for humans and terrible for hot networking paths. It's bulky, slow to parse, ambiguous about number types, and forces you to allocate strings for everything. P2P protocols move millions of small messages, so they encode data as tight binary. Today you'll learn how messages are framed on a stream, how integers are packed with varints, and how the major serialization formats trade off — then hand-write an encoder/decoder, which is exactly the kind of thing you may be asked to live-code.

4.1 Why binary beats JSON on the wire

ConcernJSONBinary
Size{"id":1000000} = 15 bytesvarint = 3 bytes
Parse costTokenize text, allocate stringsRead fixed offsets / shift bits
Number fidelityAll numbers are float64; no int64Exact integer widths
SchemaSelf-describing (keys repeated every message)Schema known by both sides; no key overhead

The self-describing nature of JSON is its biggest cost at scale: every message re-sends the field names. Binary protocols agree on a schema once, then send only values in a known order.

4.2 Framing: where does a message end?

TCP and UDX give you a stream of bytes, not messages. If you write two messages, the reader may receive them glued together, or split across two reads. You must impose framing. Two common approaches:

The framing bug everyone hits once Forgetting that a single 'data' event can contain part of a message, multiple messages, or a message plus part of the next. A correct length-prefix reader buffers incoming bytes and emits messages only when a full frame has arrived. Get this wrong and your protocol works in tests and corrupts under load.

4.3 Varints: integers that earn their bytes

Most integers in real protocols are small (lengths, counts, indices). Spending 4 or 8 fixed bytes on a value of 5 is wasteful. A varint (LEB128, as used by Protobuf, WebAssembly, and Holepunch's compact-encoding) encodes an integer in a variable number of bytes: 7 bits of value per byte, with the high bit as a "more bytes follow" continuation flag.

So small numbers cost one byte, and you only pay for big numbers when you actually use them. This is the single most common trick in compact wire formats.

4.4 The serialization landscape

FormatShapeNotes
Protocol BuffersSchema (.proto), varint-basedCompact, fast, ubiquitous; needs codegen. Tag-length-value fields.
MessagePackSchemaless, JSON-shaped"Binary JSON" — drop-in for JSON, self-describing, no schema needed.
Cap'n ProtoSchema, zero-copyMemory layout is the wire format; "infinitely faster" — no parse step.
FlatBuffersSchema, zero-copyAccess fields without unpacking the whole buffer; great for large messages read partially.
compact-encodingHand-composed codecsHolepunch's lib: tiny, explicit, varint-first. You compose encoders by hand.

The key conceptual split: parse-based formats (Protobuf, MessagePack) decode bytes into new in-memory objects, while zero-copy formats (Cap'n Proto, FlatBuffers) let you read fields directly out of the received buffer with no allocation or decode pass. Zero-copy wins when messages are large or you only read a few fields; parse-based is simpler and fine for small messages. compact-encoding sits in the parse-based camp but is deliberately minimal and gives you total control — which is why it suits a P2P stack where every byte and allocation counts.

4.5 Lab — hand-write a length-prefixed varint protocol

Lab 04

Goal

Implement varint encode/decode, then a length-prefixed framer that correctly reassembles messages from an arbitrarily-chunked byte stream. Finish by encoding a structured message by hand. Then compare against real compact-encoding.

Step 1 — varints

// varint.js
function encodeVarint(value) {
  const bytes = [];
  while (value > 0x7f) {            // while more than 7 bits remain
    bytes.push((value & 0x7f) | 0x80); // low 7 bits + continuation flag
    value = Math.floor(value / 128);  // >>> 7 but safe past 32 bits
  }
  bytes.push(value & 0x7f);            // final byte, no continuation
  return Buffer.from(bytes);
}

function decodeVarint(buf, offset = 0) {
  let result = 0, shift = 0, pos = offset;
  while (true) {
    const byte = buf[pos++];
    result += (byte & 0x7f) * Math.pow(2, shift);
    if ((byte & 0x80) === 0) break;  // continuation bit clear -> done
    shift += 7;
  }
  return { value: result, bytesRead: pos - offset };
}

// sanity
for (const v of [0, 1, 127, 128, 300, 1000000]) {
  const enc = encodeVarint(v);
  const dec = decodeVarint(enc).value;
  console.log(v, '->', [...enc], `(${enc.length} bytes) ->`, dec);
}
module.exports = { encodeVarint, decodeVarint };
node varint.js
// 0 -> [0] (1 bytes) -> 0
// 127 -> [127] (1 bytes) -> 127
// 128 -> [128,1] (2 bytes) -> 128
// 300 -> [172,2] (2 bytes) -> 300
// 1000000 -> [192,132,61] (3 bytes) -> 1000000   <- vs 15 bytes as JSON

Step 2 — a correct length-prefixed framer

This is the part interviewers love, because the naive version is subtly broken. The framer buffers raw bytes and only emits a message when a complete frame is present — handling split and coalesced reads.

// framer.js
const { encodeVarint, decodeVarint } = require('./varint');
const EventEmitter = require('events');

function frame(payload) {
  const len = encodeVarint(payload.length);
  return Buffer.concat([len, payload]); // [varint length][payload bytes]
}

class Decoder extends EventEmitter {
  constructor() { super(); this.buf = Buffer.alloc(0); }
  push(chunk) {
    this.buf = Buffer.concat([this.buf, chunk]); // accumulate
    while (this.buf.length > 0) {
      let header;
      try { header = decodeVarint(this.buf, 0); }
      catch { return; }                       // length not fully arrived
      const total = header.bytesRead + header.value;
      if (this.buf.length < total) return;     // full frame not here yet — wait
      const payload = this.buf.subarray(header.bytesRead, total);
      this.emit('message', Buffer.from(payload));
      this.buf = this.buf.subarray(total);   // keep the remainder
    }
  }
}
module.exports = { frame, Decoder };

Step 3 — prove it survives evil chunking

// test-framer.js
const { frame, Decoder } = require('./framer');

const messages = ['hello', 'a', 'a much longer message here', 'last']
  .map(s => Buffer.from(s));
const stream = Buffer.concat(messages.map(frame)); // all frames glued together

const dec = new Decoder();
const out = [];
dec.on('message', m => out.push(m.toString()));

// feed the stream in deliberately awful 3-byte chunks
for (let i = 0; i < stream.length; i += 3)
  dec.push(stream.subarray(i, i + 3));

console.log(out);
// [ 'hello', 'a', 'a much longer message here', 'last' ]  ✅ reassembled perfectly

Step 4 — encode a structured message by hand

A "peer announce" message: a 1-byte type tag, a varint topic length + topic bytes, and a fixed 6-byte address (4-byte IPv4 + 2-byte port).

// announce.js
const { encodeVarint, decodeVarint } = require('./varint');
const TYPE_ANNOUNCE = 0x01;

function encodeAnnounce({ topic, ip, port }) {
  const topicBuf = Buffer.from(topic);
  const addr = Buffer.alloc(6);
  ip.split('.').forEach((o, i) => addr[i] = Number(o));
  addr.writeUInt16BE(port, 4); // big-endian = network byte order
  return Buffer.concat([
    Buffer.from([TYPE_ANNOUNCE]),
    encodeVarint(topicBuf.length), topicBuf,
    addr
  ]);
}

function decodeAnnounce(buf) {
  let off = 0;
  const type = buf[off++];
  const { value: tlen, bytesRead } = decodeVarint(buf, off); off += bytesRead;
  const topic = buf.subarray(off, off + tlen).toString(); off += tlen;
  const ip = [...buf.subarray(off, off + 4)].join('.'); off += 4;
  const port = buf.readUInt16BE(off);
  return { type, topic, ip, port };
}

const wire = encodeAnnounce({ topic: 'chat-room-42', ip: '192.168.1.50', port: 9000 });
console.log('bytes :', wire.length, [...wire]);
console.log('decoded:', decodeAnnounce(wire));

Step 5 — the real thing: compact-encoding

npm install compact-encoding
// ce.js
const c = require('compact-encoding');

// compose a struct codec from primitives
const announce = {
  preencode(state, m) {     // pass 1: measure
    c.string.preencode(state, m.topic);
    c.uint16.preencode(state, m.port);
  },
  encode(state, m) {        // pass 2: write
    c.string.encode(state, m.topic);
    c.uint16.encode(state, m.port);
  },
  decode(state) {
    return { topic: c.string.decode(state), port: c.uint16.decode(state) };
  }
};

const buf = c.encode(announce, { topic: 'chat-room-42', port: 9000 });
console.log('compact bytes:', buf.length, [...buf]);
console.log('decoded      :', c.decode(announce, buf));

Notice the two-pass design: preencode computes the exact size so encode can write into a single pre-allocated buffer with no resizing — an allocation optimization you'll revisit on Day 6.

Experiments

  • Change the test framer chunk size to 1 byte, then to 1000. Both must produce identical output — that's the definition of correct framing.
  • Compare your hand-rolled announce size against the equivalent JSON string. Measure the ratio.
  • Add a malformed frame (a length prefix larger than the data that ever arrives) and confirm the decoder simply waits rather than crashing or emitting garbage.

4.6 Self-check & interview drill

Why length-prefix instead of a delimiter?
Binary payloads can contain any byte, including your delimiter, forcing escaping. A length prefix needs no escaping and lets the reader allocate/await exactly the right number of bytes. It's the standard for binary protocols.
Explain a varint and when it pays off.
7 value-bits per byte with a high continuation bit. Small numbers cost one byte; you only pay extra bytes for larger values. It pays off because most protocol integers (lengths, counts, indices) are small.
Parse-based vs zero-copy serialization?
Parse-based (Protobuf, MessagePack) decodes bytes into fresh objects. Zero-copy (Cap'n Proto, FlatBuffers) lets you read fields straight from the received buffer with no decode pass — better for large messages or partial reads, at the cost of a more rigid layout.
Day 05 — Trust Without a Server

Cryptographic Primitives

Signatures, key exchange, AEAD, and the Noise handshake that secures every Holepunch connection.

⏱ ~90 min · theory 50 / lab 40

In a P2P network there's no certificate authority, no trusted server, no login. Identity, integrity, and confidentiality all come from cryptography applied directly between peers. You will not implement crypto primitives yourself (rule one of applied crypto: don't roll your own), but you must understand what each primitive does, why it's there, and how they compose into the Noise Protocol handshake that hyperdht uses to secure connections. Today is concept-heavy with a lab using sodium-native, the same library the Holepunch stack relies on.

5.1 The four jobs crypto does here

JobPrimitiveQuestion it answers
Identity / authenticityEd25519 signatures"Did the holder of this key really produce this?"
Key agreementX25519 (Curve25519 ECDH)"How do two strangers derive a shared secret over a public channel?"
Confidentiality + integrityChaCha20-Poly1305 / AES-GCM (AEAD)"How do we encrypt so tampering is detectable?"
Fingerprinting / commitmentBLAKE2b hashing, Merkle trees"How do we name and verify data compactly?"

5.2 Ed25519 — identity as a keypair

In Hypercore the public key is the address of a feed, and the secret key authorizes appends. Ed25519 is the signature scheme behind this: fast, deterministic (no risky randomness at signing time, the flaw that has broken ECDSA implementations), small keys and signatures (32 and 64 bytes). A signature proves a message was produced by the holder of the secret key and hasn't been altered. This is the entire basis of "single writer" — only one party can sign valid appends, and every reader can verify them with just the public key.

5.3 X25519 — agreeing on a secret in the open

Two peers who have never met need a shared symmetric key to encrypt their session. Diffie–Hellman over Curve25519 (X25519) lets each side combine its own secret with the other's public value to compute the same shared secret, while an eavesdropper who sees only the public values cannot. Using fresh ephemeral keypairs per session gives forward secrecy: even if a long-term key leaks later, past sessions stay private because their ephemeral secrets are already gone.

Signing vs key-exchange keys Ed25519 (signing) and X25519 (key exchange) are sister curves — same underlying Curve25519 math, different jobs. Don't conflate them: you sign with Ed25519 to prove identity, and DH with X25519 to derive session keys.

5.4 AEAD — encrypt and authenticate together

Plain encryption hides content but doesn't stop an attacker flipping bits. AEAD (Authenticated Encryption with Associated Data) — ChaCha20-Poly1305 or AES-GCM — encrypts and produces an authentication tag, so any tampering is detected on decryption. "Associated data" lets you bind unencrypted context (like a header) to the ciphertext so it can't be swapped. Every byte of an established Noise session rides inside AEAD frames.

5.5 BLAKE2b and Merkle trees, revisited

You met Merkle trees on Day 2. The hash underneath them in Hypercore is BLAKE2b — faster than SHA-256, with strong security margins. Hashing gives you fixed-size, collision-resistant fingerprints used for content addressing, the discovery key derived from a feed's public key, and the block/tree hashes that make partial replication verifiable.

5.6 The Noise Protocol Framework

Noise is a framework for building secure handshakes by composing the primitives above into a fixed sequence of DH operations. It's what WireGuard, WhatsApp, and Holepunch's transport use. A Noise handshake is named by a pattern of message tokens:

Each handshake message mixes new DH outputs into a running shared state; by the end both sides have derived matching symmetric keys for an AEAD-encrypted session with forward secrecy. The elegance is that the pattern declares the security properties, and the framework guarantees them.

Holepunch note When you connect through hyperdht/hyperswarm, the peers run a Noise handshake (an IK-style pattern fits the "I looked up your key in the DHT, now I'm dialing you" model) to authenticate and establish an encrypted, forward-secret channel — before any application data flows. The primitives are Ed25519/X25519/BLAKE2b/ChaCha20-Poly1305 via sodium-native. Being able to say "Noise IK, because the dialer already has the responder's static key from the DHT lookup" is a top-tier interview answer.

5.7 Lab — signatures, key exchange, AEAD, and a mini DH handshake

Lab 05

Goal

Use real, production primitives to (1) sign and verify like a Hypercore writer, (2) perform an X25519 key exchange and confirm both sides derive the same secret, (3) AEAD-encrypt with tamper detection, and (4) sketch the shape of a Noise-style handshake.

Setup

npm install sodium-native

sodium-native is libsodium bindings — the exact crypto layer under Hypercore. (If it fails to build in your environment, Node's built-in crypto also supports Ed25519/X25519; the concepts are identical.)

Step 1 — Ed25519 sign & verify (the "single writer" proof)

// sign.js
const sodium = require('sodium-native');

// generate a signing keypair
const pk = Buffer.alloc(sodium.crypto_sign_PUBLICKEYBYTES); // 32
const sk = Buffer.alloc(sodium.crypto_sign_SECRETKEYBYTES); // 64
sodium.crypto_sign_keypair(pk, sk);

const message = Buffer.from('append: block #42');
const sig = Buffer.alloc(sodium.crypto_sign_BYTES);   // 64
sodium.crypto_sign_detached(sig, message, sk);

const ok = sodium.crypto_sign_verify_detached(sig, message, pk);
console.log('signature valid:', ok);

// tamper -> verification fails (integrity + authenticity)
const forged = Buffer.from('append: block #43');
console.log('forged valid  :', sodium.crypto_sign_verify_detached(sig, forged, pk));

Step 2 — X25519 key exchange (two strangers, one secret)

// dh.js
const sodium = require('sodium-native');

function keypair() {
  const pk = Buffer.alloc(sodium.crypto_kx_PUBLICKEYBYTES);
  const sk = Buffer.alloc(sodium.crypto_kx_SECRETKEYBYTES);
  sodium.crypto_kx_keypair(pk, sk);
  return { pk, sk };
}

const alice = keypair();
const bob = keypair();

// each derives session keys from own secret + peer's public key
const aRx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const aTx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const bRx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);
const bTx = Buffer.alloc(sodium.crypto_kx_SESSIONKEYBYTES);

sodium.crypto_kx_client_session_keys(aRx, aTx, alice.pk, alice.sk, bob.pk);
sodium.crypto_kx_server_session_keys(bRx, bTx, bob.pk, bob.sk, alice.pk);

// alice's TX key == bob's RX key  -> shared secret agreed over a public channel
console.log('keys match:', aTx.equals(bRx) && bTx.equals(aRx));

Step 3 — AEAD encryption with tamper detection

// aead.js
const sodium = require('sodium-native');

const key = Buffer.alloc(sodium.crypto_aead_chacha20poly1305_ietf_KEYBYTES);
sodium.randombytes_buf(key);
const nonce = Buffer.alloc(sodium.crypto_aead_chacha20poly1305_ietf_NPUBBYTES);
sodium.randombytes_buf(nonce); // MUST be unique per message under a key

const plaintext = Buffer.from('secret peer payload');
const ad = Buffer.from('msg-type:3'); // associated data: authenticated, not encrypted
const ct = Buffer.alloc(plaintext.length + sodium.crypto_aead_chacha20poly1305_ietf_ABYTES);

sodium.crypto_aead_chacha20poly1305_ietf_encrypt(ct, plaintext, ad, null, nonce, key);

const out = Buffer.alloc(ct.length - sodium.crypto_aead_chacha20poly1305_ietf_ABYTES);
sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, ad, nonce, key);
console.log('decrypted:', out.toString());

// flip one ciphertext byte -> decrypt throws (the Poly1305 tag fails)
ct[0] ^= 0xff;
try {
  sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, ad, nonce, key);
  console.log('tamper undetected (BAD)');
} catch {
  console.log('tamper detected: decryption rejected ✅');
}

Step 4 — sketch a Noise-style handshake

This is a conceptual 2-message exchange (not a spec-compliant Noise impl) to show how DH + hashing compose into a session. Each side mixes ephemeral DH output into a running hash to derive a shared key.

// mini-noise.js — illustrative only; use the 'noise-protocol' / hyperswarm libs for real
const sodium = require('sodium-native');
const hash = (b) => { const o = Buffer.alloc(32); sodium.crypto_generichash(o, b); return o; };

function eph() {
  const pk = Buffer.alloc(32), sk = Buffer.alloc(32);
  sodium.crypto_box_keypair(pk, sk); return { pk, sk };
}
function dh(sk, pk) {
  const out = Buffer.alloc(32);
  sodium.crypto_scalarmult(out, sk, pk); return out;
}

const i = eph(), r = eph();        // initiator + responder ephemerals
// -> e   : initiator sends its ephemeral public key
// <- e   : responder sends its ephemeral public key
// both compute the same DH(e_i, e_r) and mix it into a session key
const kI = hash(dh(i.sk, r.pk));
const kR = hash(dh(r.sk, i.pk));
console.log('handshake keys match:', kI.equals(kR));
console.log('(real Noise also mixes static keys + transcript hash for auth)');

Experiments

  • In Step 3, reuse the same nonce for two messages. Crypto won't error, but you've just broken the cipher's security — this is why nonce management is the #1 AEAD footgun. Note how real protocols derive per-message nonces from a counter.
  • In Step 4, generate fresh ephemerals each run and confirm the derived key changes every time — that's forward secrecy in action.
  • Look up the noise-protocol npm package and identify which functions correspond to the XX vs IK patterns.

5.8 Self-check & interview drill

Why Ed25519 for identity and X25519 for sessions — why two keys?
Different jobs: Ed25519 signs to prove a message came from a specific identity; X25519 does Diffie–Hellman to derive a shared session secret. They share Curve25519 math but aren't interchangeable.
What does AEAD add over plain encryption?
An authentication tag, so tampering with ciphertext (or the bound associated data) is detected and rejected on decrypt. Confidentiality plus integrity in one operation.
XX vs IK Noise patterns — when each?
XX when neither side knows the other's static key in advance (mutual auth during the handshake). IK when the initiator already has the responder's static key — e.g. dialing a peer whose public key came from a DHT lookup — giving fewer round trips and initiator-identity hiding. That's the natural fit for hyperdht.
What gives a session forward secrecy?
Ephemeral per-session DH keypairs. Once discarded, a later compromise of long-term keys can't decrypt past sessions, because the ephemeral secrets that derived those session keys no longer exist.
Day 06 — Going Fast

High-Performance JS Networking

The event loop, buffers, backpressure, and the allocation discipline that separates a toy from a relay.

⏱ ~90 min · theory 45 / lab 45

You can know every protocol and still write a P2P node that melts under load. Performance in Node networking is mostly about three things: respecting the event loop, managing buffers without drowning the garbage collector, and honoring backpressure so a fast sender can't overwhelm a slow consumer. This chapter is where the relay-and-throughput intuition that interviewers probe actually lives.

6.1 The event loop is your whole performance model

Node runs your JavaScript on a single thread. I/O (sockets, files) is handed off to libuv and completes asynchronously, but every callback, every data handler, every bit of parsing runs on that one thread. The rule that follows is absolute: never block the loop. A synchronous JSON.parse of a huge payload, a tight CPU loop, a synchronous crypto call on every packet — each one stalls all connections, not just one. Throughput collapses not because the network is slow but because the loop is busy.

Mental model Think of the event loop as a single cashier serving every customer. The cashier is extremely fast, but if you ask them to do long division by hand for one customer, the entire queue waits. Your job is to keep every interaction short and push the long division elsewhere.

6.2 Buffers and the GC tax

Every Buffer you allocate in a hot path is future work for the garbage collector. Under high packet rates, allocation churn causes GC pauses that show up as latency spikes and jitter. The disciplines that matter:

Holepunch note This is exactly the discipline a relay like a HiveRelay node lives or dies by. A relay forwards huge numbers of small frames; if each forwarded frame allocates a fresh buffer and triggers a concat, GC pressure caps your throughput long before bandwidth does. Pre-sized read buffers, subarray views for forwarding, and bounded per-event work are what let a single Node process push serious packet rates.

6.3 Backpressure: the rule everyone forgets

socket.write(chunk) returns a boolean. When it returns false, the kernel/stream buffer is full and you must stop writing until the 'drain' event fires. Ignore this and Node happily queues your data in memory — unbounded — until the process balloons and dies. A fast producer feeding a slow socket is the most common way a P2P node runs out of memory.

The clean solutions: use stream.pipe() (which handles backpressure for you), or pipeline(), or honor the write/'drain' contract manually. Async iterators (for await...of over a readable) also propagate backpressure naturally.

6.4 Measuring: latency vs throughput

Two different numbers people constantly conflate:

You can have great throughput and terrible tail latency (big batches, long GC pauses) or low latency and poor throughput (tiny unbatched writes with per-message overhead). Always measure percentiles (p50, p99), never just the mean — the mean hides exactly the stalls that matter. perf_hooks.monitorEventLoopDelay() quantifies how much the loop itself is the bottleneck.

6.5 Lab — find and fix the throughput killers

Lab 06

Goal

Measure three real performance effects: (1) the Buffer.concat O(n²) trap vs a pre-sized buffer, (2) allocUnsafe vs alloc in a hot loop, and (3) backpressure on a TCP socket. You'll produce numbers, not just theory.

Step 1 — the accumulating-concat trap

// concat-trap.js — why naive reassembly is O(n^2)
const { performance } = require('perf_hooks');
const CHUNKS = 20000;
const chunk = Buffer.allocUnsafe(64);

// BAD: concat the whole growing buffer on every chunk
let t = performance.now();
let acc = Buffer.alloc(0);
for (let i = 0; i < CHUNKS; i++) acc = Buffer.concat([acc, chunk]);
console.log('concat-each-time :', (performance.now() - t).toFixed(1), 'ms');

// GOOD: collect references, concat ONCE at the end
t = performance.now();
const parts = [];
for (let i = 0; i < CHUNKS; i++) parts.push(chunk);
const joined = Buffer.concat(parts);
console.log('collect-then-join:', (performance.now() - t).toFixed(1), 'ms');

// BEST when total size known: one allocation, copy into it
t = performance.now();
const dst = Buffer.allocUnsafe(CHUNKS * 64);
for (let i = 0; i < CHUNKS; i++) chunk.copy(dst, i * 64);
console.log('presized-copy    :', (performance.now() - t).toFixed(1), 'ms');
node concat-trap.js
// concat-each-time : 3380.0 ms    <- quadratic; explodes with size
// collect-then-join:    2.3 ms     <- linear
// presized-copy    :    6.9 ms     <- linear, one allocation

The exact numbers vary by machine, but the shape is the lesson: the naive version is hundreds to thousands of times slower and gets worse as the buffer grows. This is the framing decoder from Day 4 done wrong.

Step 2 — allocUnsafe vs alloc in a hot path

// alloc-bench.js
const { performance } = require('perf_hooks');
const N = 2_000_000, SIZE = 256;

let t = performance.now();
for (let i = 0; i < N; i++) { const b = Buffer.alloc(SIZE); b[0] = 1; }
console.log('alloc      :', (performance.now() - t).toFixed(0), 'ms');

t = performance.now();
for (let i = 0; i < N; i++) { const b = Buffer.allocUnsafe(SIZE); b[0] = 1; }
console.log('allocUnsafe:', (performance.now() - t).toFixed(0), 'ms');

// BEST: one reusable buffer, no per-iteration allocation at all
t = performance.now();
const reusable = Buffer.allocUnsafe(SIZE);
for (let i = 0; i < N; i++) { reusable[0] = 1; }
console.log('reused     :', (performance.now() - t).toFixed(0), 'ms');

Zero-filling (alloc) costs measurable time at scale, and not allocating at all wins biggest. In a real reader you keep one read buffer and parse out of it.

Step 3 — backpressure on a real socket

// backpressure.js — a server that floods, a client that reads slowly
const net = require('net');

const server = net.createServer((socket) => {
  const payload = Buffer.allocUnsafe(64 * 1024); // 64 KB
  let sent = 0, blocked = 0;

  function pump() {
    while (sent < 2000) {
      const ok = socket.write(payload); // returns false when buffer is full
      sent++;
      if (!ok) {                       // BACKPRESSURE: stop and wait for drain
        blocked++;
        socket.once('drain', pump);     // resume only when flushed
        return;
      }
    }
    console.log(`done: sent ${sent} chunks, paused ${blocked} times for drain`);
    socket.end();
  }
  pump();
});

server.listen(8124, () => {
  const client = net.connect(8124);
  let received = 0;
  client.on('data', (d) => {
    received += d.length;
    client.pause();                       // simulate a slow consumer
    setTimeout(() => client.resume(), 1);
  });
  client.on('end', () => {
    console.log(`client received ${(received / 1024 / 1024).toFixed(1)} MB`);
    server.close();
  });
});
node backpressure.js
// done: sent 2000 chunks, paused 80+ times for drain
// client received 125.0 MB

The "paused for drain" count is the system protecting itself. Delete the if (!ok) branch and keep writing regardless: memory usage climbs without bound because Node queues everything you hand it. That is the bug behind most "my P2P node leaks memory under load" reports.

Experiments

  • In Step 1, raise CHUNKS to 40000 and watch the concat time roughly quadruple (4×), confirming O(n²) — while the other two only double.
  • In Step 3, log socket.writableLength each time write returns false to see the high-water mark Node is enforcing.
  • Add const h = require('perf_hooks').monitorEventLoopDelay(); h.enable(); around a blocking loop and read h.max to quantify a stall in milliseconds.

6.6 Self-check & interview drill

Why is "don't block the event loop" the cardinal rule?
Node serves all connections on one JS thread. Any synchronous CPU work (big parse, tight loop, sync crypto) stalls every connection simultaneously, so throughput and latency collapse together. Heavy work goes to worker threads or native addons that release the loop.
When allocUnsafe over alloc, and what's the risk?
When you'll immediately overwrite every byte — it skips zero-filling, which is faster. The risk is leaking uninitialized memory if you send a buffer whose tail you didn't actually write, so always bound what you transmit to what you filled.
What is backpressure and how do you honor it?
socket.write returning false means the buffer is full; you must stop writing until 'drain'. Honor it via pipe/pipeline, async iteration, or the manual write/drain dance. Ignoring it causes unbounded memory growth.
Why measure p99 latency, not the mean?
The mean hides tail stalls — GC pauses, event-loop blocking — which are exactly what degrade real user experience and what a relay must control. Percentiles surface them.
Day 07 — Proof & Capstone

Profiling, Optimization & Integration

Flame graphs that prove your optimization worked — then build the whole stack end to end.

⏱ ~2 hrs · theory 40 / capstone 80

Optimization without measurement is superstition. Today you learn to find the bottleneck before touching code, then you assemble everything from Days 1–6 into a single working P2P node: DHT-style discovery, a Noise-style handshake, length-prefixed binary framing, and verified message exchange. This is also your interview rehearsal — the capstone is the system-design answer.

7.1 Measure first: the profiling toolkit

ToolWhat it showsWhen to reach for it
node --profV8 sampling profile → process with --prof-processFirst look at where CPU time goes, zero deps
node --cpu-profWrites a .cpuprofile for Chrome DevToolsVisual flame graph in a familiar UI
clinic doctorDiagnoses the kind of problem (CPU, I/O, GC, event-loop)"It's slow and I don't know why"
clinic flame / 0xFlame graph of stack frames by timePinpoint the hot function
clinic bubbleprofAsync operation flow & latencyAsync/await and I/O latency chains
perf_hooksPrecise in-process timing & event-loop delayTargeted micro-measurements & CI gates

Reading a flame graph

The x-axis is not time — it's the share of samples a function (and its callees) appeared in. Width = cost. Height = call-stack depth. You hunt for wide plateaus: a single wide frame is a function burning CPU directly; a wide frame with narrow children is the leaf where the time actually goes. Optimizing a narrow frame, however slow it feels, moves nothing. Wide first, always.

The discipline Measure → form a hypothesis about the widest frame → change one thing → measure again. If the flame graph didn't get narrower where you expected, revert. Most "optimizations" made without this loop are noise or regressions.

7.2 The optimization order of operations

  1. Algorithm first. An O(n²)O(n) fix (the Day 6 concat trap) beats any micro-tuning.
  2. Allocation second. Cut buffer churn in hot paths; reuse and slice instead of copy.
  3. Don't block the loop third. Move heavy CPU off-thread; keep per-event work bounded.
  4. Micro-optimize last, only with a flame graph pointing at the exact frame.

7.3 Lab — profile a deliberately slow path

Lab 07a

Goal

Generate a CPU profile, read it, fix the hot spot, and confirm the win — the full measure-fix-measure loop.

// slow.js — a "message processor" with a hidden hot spot
const { performance } = require('perf_hooks');

function processMessages(n) {
  let log = '';
  for (let i = 0; i < n; i++) {
    log += JSON.stringify({ id: i, ok: true }) + '\n'; // string += in a loop = O(n^2)-ish
  }
  return log.length;
}

const t = performance.now();
console.log('len:', processMessages(200000));
console.log('took:', (performance.now() - t).toFixed(0), 'ms');
# profile it
node --cpu-prof --cpu-prof-name=slow.cpuprofile slow.js
# open slow.cpuprofile in Chrome DevTools (Performance tab) -> see the wide frame

# or the zero-dependency route:
node --prof slow.js
node --prof-process isolate-*.log > profile.txt
# inspect the "Summary" and "Bottom up (heavy) profile" sections

The profile fingers string concatenation / stringify as the wide frame. The fix is the same shape as Day 6: collect parts, join once.

// fast.js — same output, collect-then-join
function processMessages(n) {
  const parts = [];
  for (let i = 0; i < n; i++) parts.push('{"id":' + i + ',"ok":true}');
  return parts.join('\n').length;
}

Re-profile and confirm the plateau shrank. That's the loop — never trust a fix you didn't re-measure.

7.4 Capstone — a complete P2P node

Now we wire the week together. This single program runs a tiny in-memory "DHT" to discover a peer's address, runs an X25519 handshake to derive a shared key, then exchanges length-prefixed, AEAD-encrypted binary messages — the whole Holepunch-shaped pipeline in one file, using only Node built-ins plus sodium-native.

Lab 07b — Capstone

What it integrates

  • Day 1/3: a registry/rendezvous resolves a topic → peer address (DHT discovery, condensed).
  • Day 5: X25519 key agreement → a shared AEAD key (the handshake).
  • Day 4: length-prefixed varint framing over a UDP/stream boundary.
  • Day 5: ChaCha20-Poly1305 AEAD on every message, with tamper rejection.
  • Day 6: bounded per-event work, slice-not-copy framing, no concat-in-loop.

The capstone (run with node capstone.js)

// capstone.js — discovery + handshake + framed encrypted messaging, in-process
const sodium = require('sodium-native');
const EventEmitter = require('events');

// ---------- Day 4: varint + length-prefix framing ----------
function encodeVarint(v) {
  const b = [];
  while (v > 0x7f) { b.push((v & 0x7f) | 0x80); v = Math.floor(v / 128); }
  b.push(v & 0x7f); return Buffer.from(b);
}
function decodeVarint(buf, off = 0) {
  let r = 0, s = 0, p = off;
  while (true) {
    const x = buf[p++]; if (x === undefined) throw new Error('incomplete');
    r += (x & 0x7f) * Math.pow(2, s); if ((x & 0x80) === 0) break; s += 7;
  }
  return { value: r, bytesRead: p - off };
}
class Framer extends EventEmitter {
  constructor() { super(); this.buf = Buffer.alloc(0); }
  push(chunk) {
    this.buf = Buffer.concat([this.buf, chunk]);
    while (this.buf.length) {
      let h; try { h = decodeVarint(this.buf); } catch { return; }
      const total = h.bytesRead + h.value;
      if (this.buf.length < total) return;
      this.emit('message', Buffer.from(this.buf.subarray(h.bytesRead, total)));
      this.buf = this.buf.subarray(total);
    }
  }
}
const frame = (p) => Buffer.concat([encodeVarint(p.length), p]);

// ---------- Day 5: handshake + AEAD ----------
function kxKeypair() {
  const pk = Buffer.alloc(sodium.crypto_kx_PUBLICKEYBYTES);
  const sk = Buffer.alloc(sodium.crypto_kx_SECRETKEYBYTES);
  sodium.crypto_kx_keypair(pk, sk); return { pk, sk };
}
const A_BYTES = sodium.crypto_aead_chacha20poly1305_ietf_ABYTES;
const N_BYTES = sodium.crypto_aead_chacha20poly1305_ietf_NPUBBYTES;
function seal(key, counter, msg) {
  const nonce = Buffer.alloc(N_BYTES); nonce.writeUInt32LE(counter, 0); // per-message nonce
  const ct = Buffer.alloc(msg.length + A_BYTES);
  sodium.crypto_aead_chacha20poly1305_ietf_encrypt(ct, msg, null, null, nonce, key);
  return ct;
}
function open(key, counter, ct) {
  const nonce = Buffer.alloc(N_BYTES); nonce.writeUInt32LE(counter, 0);
  const out = Buffer.alloc(ct.length - A_BYTES);
  sodium.crypto_aead_chacha20poly1305_ietf_decrypt(out, null, ct, null, nonce, key);
  return out;
}

// ---------- Day 1/3: a trivial discovery registry ----------
const registry = new Map(); // topic -> { inbox: Framer, key, counter }

function makePeer(name, topic) {
  const kp = kxKeypair();
  const inbox = new Framer();
  const peer = { name, topic, kp, inbox, key: null, rxCounter: 0, txCounter: 0, partner: null };
  registry.set(name, peer);
  return peer;
}

// "send" = hand framed bytes to the partner's inbox (stands in for the wire)
function send(from, payload) {
  const ct = seal(from.key, from.txCounter, payload);
  from.txCounter++;
  from.partner.inbox.push(frame(ct)); // length-prefixed encrypted frame
}

// ---------- wire it together ----------
const alice = makePeer('alice', 'chat-42');
const bob = makePeer('bob', 'chat-42');
alice.partner = bob; bob.partner = alice;

// discovery + handshake: derive a shared AEAD key from kx
function handshake(client, server) {
  const cRx = Buffer.alloc(32), cTx = Buffer.alloc(32);
  const sRx = Buffer.alloc(32), sTx = Buffer.alloc(32);
  sodium.crypto_kx_client_session_keys(cRx, cTx, client.kp.pk, client.kp.sk, server.kp.pk);
  sodium.crypto_kx_server_session_keys(sRx, sTx, server.kp.pk, server.kp.sk, client.kp.pk);
  // use one direction's key for both here (demo); real protocols key each direction
  client.key = cTx; server.key = sRx; // cTx === sRx
  return cTx.equals(sRx);
}
console.log('handshake ok:', handshake(alice, bob));

// bob decrypts and verifies each framed message he receives
bob.inbox.on('message', (ct) => {
  try {
    const msg = open(bob.key, bob.rxCounter, ct);
    bob.rxCounter++;
    console.log(`bob received: "${msg.toString()}"`);
  } catch {
    console.log('bob: message failed AEAD verification — dropped');
  }
});

// alice sends three encrypted, framed messages
send(alice, Buffer.from('hello bob'));
send(alice, Buffer.from('this channel is encrypted'));
send(alice, Buffer.from('and every frame is verified'));

// tamper demo: flip a byte in a sealed frame and confirm it's rejected
const good = seal(alice.key, 99, Buffer.from('evil'));
good[2] ^= 0xff;
bob.rxCounter = 99;
bob.inbox.push(frame(good)); // -> "failed AEAD verification — dropped"

Expected output

node capstone.js
// handshake ok: true
// bob received: "hello bob"
// bob received: "this channel is encrypted"
// bob received: "and every frame is verified"
// bob: message failed AEAD verification — dropped

From this skeleton to the real Holepunch stack

Each hand-rolled piece maps to a production module: the registry → hyperdht; the kx handshake → the Noise/UDX transport inside hyperswarm; the framer → compact-encoding + the stream protocol; the append-and-verify pattern → hypercore. Rebuild the capstone using hyperswarm for real: join a topic, and on 'connection' you get an already-encrypted, already-framed stream — every layer you just built by hand, productionized.

Experiments

  • Replace the in-process registry with the real dgram rendezvous from Day 3 so the two peers run as separate processes.
  • Swap the hand framer for compact-encoding structs (Day 4) and confirm identical behavior.
  • Profile the capstone under 100k messages with --cpu-prof; confirm no accidental concat-in-loop and that AEAD dominates CPU (as it should).

7.5 Final interview drill

Walk me through two peers behind NATs connecting and exchanging a secure message.
They discover each other via a DHT (Kademlia/XOR routing) that also acts as the rendezvous; both UDP-hole-punch to open direct paths; they run a Noise (IK) handshake using X25519 to derive a forward-secret shared key; then they exchange length-prefixed binary frames, each sealed with ChaCha20-Poly1305 AEAD so tampering is detected. That's Days 1, 3, 4, 5 in one sentence.
Why XOR distance? (the perennial)
It's symmetric and unidirectional, so lookups converge along consistent paths and routing tables reinforce each other, giving O(log N) hops.
How would you serialize a high-rate message type efficiently?
Binary with a known schema, varints for small integers, fixed widths for addresses, length-prefix framing, and a two-pass preencode/encode so it lands in one pre-sized buffer — avoiding per-message allocation.
A replication path is slow — how do you find out why?
Profile before guessing: --cpu-prof or clinic flame for a flame graph, look for the widest frame. Check for concat-in-loop (O(n²)), per-message allocation/GC pressure, event-loop blocking from sync work, and missing backpressure. Fix the widest frame, then re-measure.

You've now built, by hand, a small version of every layer in a modern P2P stack — and you can explain each one and prove it runs. Walk into the interview and rebuild the capstone on a whiteboard from the five-layer summary above. That's the whole game.