Pp2pbuilders
learn › The P2P Engineer's Field Manual

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

Day 07: 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


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.


What it integrates

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

7.5 Final interview drill

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.

« Day 6: High-Performance JS Networking Why peer-to-peer »