Most peer-to-peer tutorials in JavaScript use WebRTC. WebRTC was designed for browser-to-browser communication: video calls, screen sharing, data channels. Using it for general P2P applications is possible but awkward. You still need a signalling server. NAT traversal depends on STUN/TURN servers. The API is complex because it was designed for media streams, not arbitrary data exchange.
The Holepunch stack (Hyperswarm, Hypercore, Hyperbee) takes a different approach. It was designed from the ground up for P2P data structures and networking. No signalling servers. No STUN/TURN dependency. NAT traversal is built into the protocol.
What is the Holepunch ecosystem
The Holepunch stack has three main components:
Hypercore: an append-only log. Think of it as a linked list where each entry is cryptographically signed and linked to the previous one. Data can only be appended, never modified. Each log has a unique public key that identifies it.
Hyperbee: a B-tree built on top of Hypercore. It gives you key-value storage with sorted iteration, range queries, and sub-databases. Built on the append-only log, so every state change is recorded.
Hyperswarm: the networking layer. Handles peer discovery and connection without central servers. Uses a distributed hash table (DHT) for finding peers and handles NAT traversal using hole punching techniques.
Hyperswarm: peer discovery without a server
Traditional P2P networking needs a way for peers to find each other. WebRTC uses a signalling server. BitTorrent uses trackers. Hyperswarm uses a DHT (distributed hash table).
import Hyperswarm from 'hyperswarm';
const swarm = new Hyperswarm();
// Join a topic (a 32-byte key)
const topic = Buffer.alloc(32).fill('my-app-topic');
const discovery = swarm.join(topic, { client: true, server: true });
// Wait for connections
swarm.on('connection', (socket, info) => {
console.log('New peer connected');
socket.on('data', (data) => {
console.log('Received:', data.toString());
});
socket.write('Hello from peer');
});
// Announce presence and start looking for peers
await discovery.flushed();
console.log('Joined swarm, waiting for peers...');
When you join a topic, Hyperswarm:
- Announces your presence on the DHT
- Looks up other peers who joined the same topic
- Establishes direct connections using hole punching
Hole punching traverses most NATs without a relay server. When hole punching fails (symmetric NATs), Hyperswarm falls back to a relay through the DHT nodes themselves. This means connections succeed in environments where WebRTC would require a TURN server.
Hyperbee: a P2P key-value database
Hyperbee builds a B-tree on top of Hypercore's append-only log:
import Hypercore from 'hypercore';
import Hyperbee from 'hyperbee';
const core = new Hypercore('./db/my-database');
const db = new Hyperbee(core, {
keyEncoding: 'utf-8',
valueEncoding: 'json',
});
// Write
await db.put('user:alice', { name: 'Alice', registered: Date.now() });
await db.put('user:bob', { name: 'Bob', registered: Date.now() });
// Read
const entry = await db.get('user:alice');
console.log(entry.value); // { name: 'Alice', registered: ... }
// Range query
for await (const entry of db.createReadStream({
gte: 'user:',
lt: 'user:~',
})) {
console.log(entry.key, entry.value);
}
The append-only log underneath means every put operation creates a new entry in the log. Old values aren't overwritten. You can read the database at any historical version. This is event sourcing by default, without additional infrastructure.
Building a username registry
As a concrete example, I built a decentralised username registry where peers can claim usernames and look up other users.
import Hyperswarm from 'hyperswarm';
import Hypercore from 'hypercore';
import Hyperbee from 'hyperbee';
class UsernameRegistry {
constructor(storagePath) {
this.core = new Hypercore(storagePath);
this.db = new Hyperbee(this.core, {
keyEncoding: 'utf-8',
valueEncoding: 'json',
});
this.swarm = new Hyperswarm();
this.peers = new Set();
}
async start() {
await this.core.ready();
// Join swarm with the core's discovery key
const discovery = this.swarm.join(this.core.discoveryKey, {
client: true,
server: true,
});
this.swarm.on('connection', (socket) => {
// Replicate the database with connected peers
const stream = this.core.replicate(socket);
this.peers.add(socket);
socket.on('close', () => this.peers.delete(socket));
});
await discovery.flushed();
}
async register(username, publicKey) {
const existing = await this.db.get(`username:${username}`);
if (existing) {
throw new Error(`Username "${username}" is already taken`);
}
await this.db.put(`username:${username}`, {
publicKey,
registeredAt: Date.now(),
});
return { username, publicKey };
}
async lookup(username) {
const entry = await this.db.get(`username:${username}`);
return entry ? entry.value : null;
}
async list() {
const users = [];
for await (const entry of this.db.createReadStream({
gte: 'username:',
lt: 'username:~',
})) {
users.push({
username: entry.key.replace('username:', ''),
...entry.value,
});
}
return users;
}
}
When peers connect, they replicate the Hypercore. This means every peer gets a copy of the database. Changes propagate automatically. There's no central server storing the data.
Testing P2P code
Testing P2P systems is harder than testing client-server systems because you can't mock a real peer network. The tests need actual peers.
import { test } from 'node:test';
import assert from 'node:assert';
test('two peers can share a username registry', async () => {
const registry1 = new UsernameRegistry('./test-db-1');
const registry2 = new UsernameRegistry('./test-db-2');
await registry1.start();
await registry2.start();
// Registry 1 registers a username
await registry1.register('alice', 'pk_alice_123');
// Wait for replication
await new Promise(resolve => setTimeout(resolve, 1000));
// Registry 2 should see the username
const result = await registry2.lookup('alice');
assert.equal(result.publicKey, 'pk_alice_123');
// Cleanup
await registry1.swarm.destroy();
await registry2.swarm.destroy();
});
The setTimeout for replication isn't ideal. In practice, you need to wait for the replication event:
core.on('append', () => {
// New data has been replicated
});
Testing edge cases (network partitions, conflicting writes, peers joining and leaving) requires simulating network conditions, which Hyperswarm doesn't provide tools for. We test these scenarios with integration tests that create and destroy swarms programmatically.
Operational considerations
A system with no central server means:
- No single point of failure: if one peer goes down, others continue. The data is replicated across all peers.
- No central monitoring: you can't check a server's health. Monitoring requires peers to report their status to a collection point, which reintroduces some centralization.
- No access control by default: anyone who knows the topic can join the swarm. Application-level authentication and encryption are your responsibility.
- Consistency is eventual: when peers are partitioned, they diverge. When they reconnect, they converge. There's no strong consistency guarantee.
For the username registry, eventual consistency means two peers could register the same username while disconnected. Conflict resolution needs application-level logic, for example first-write-wins based on the Hypercore sequence number.
The Holepunch stack is the most capable P2P toolkit available for JavaScript. It handles the hard networking problems (NAT traversal, peer discovery) and provides data structures (append-only logs, B-trees) that make building P2P applications practical rather than theoretical.