DEEP DIVE: LIBP2P

libp2p: A Modular Network Stack, from PeerIds to Decentralized Hole Punching

Every P2P project used to reinvent its own networking layer (naming peers, finding them, punching through NATs), usually badly. libp2p pulled those concerns apart into swappable modules and became the shared substrate underneath IPFS, Ethereum's consensus layer, Filecoin, and Polkadot. This page walks the stack from PeerIds and self-describing multiaddrs through transport, security, and pubsub, then spends real time on the payoff: how AutoNAT, Circuit Relay v2, and DCUtR let peers punch through NATs using nothing but other peers, no STUN or TURN server required.

What libp2p is

Every P2P application eventually has to solve the same handful of problems: how do you name a peer, find one, open a connection to it through whatever NAT sits in the way, encrypt that connection, and multiplex it into multiple logical streams. For years, every project solved these problems from scratch, and mostly solved them badly. As the people who built it put it, existing solutions tended to have "poor documentation, restrictive licensing, outdated code, no point of contact... or were tightly coupled with specific use cases and not upgradeable."

libp2p ("library peer-to-peer") started life inside the InterPlanetary File System (IPFS) project as IPFS's own wire protocol, then split out into a standalone, general-purpose networking stack. The idea was to stop treating peer discovery, transport, security, and multiplexing as one monolithic protocol and instead expose each concern as a swappable module: the same application can run over TCP or WebRTC, authenticate with Noise or TLS, and discover peers via a DHT or local mDNS, all without changing its business logic.

That modularity is why libp2p shows up so far outside the IPFS family it was born into. Ethereum's proof-of-stake consensus layer runs on it: both the Lighthouse and Prysm clients use libp2p for peer networking. Filecoin, "the largest decentralized storage network," depends on it for reliability at scale. Polkadot leverages it as part of its Substrate architecture, and projects from Algorand to Starknet, Uniswap, and Base list it among their networking foundations. What started as one project's wire format has become P2P's shared connective tissue.

ProjectHow it uses libp2p
IPFSOriginal creator and still the reference user, for decentralized content distribution across hundreds of thousands of nodes
Ethereum (consensus layer)Peer networking for proof-of-stake clients (Lighthouse, Prysm)
FilecoinNetwork performance and reliability for the largest decentralized storage network
PolkadotPart of the Substrate-based architecture
AlgorandTransitioning away from centralized relay nodes

Identity: PeerIds and public keys

PeerId
A verifiable link between a peer and its public cryptographic key: conceptually, a cryptographic hash of that key. Because the hash ties directly back to the key, a peer receiving a secure-channel handshake can check that the key used to secure the channel is the same key the PeerId claims to identify.
multihash
A compact, self-describing binary format for hashes that libp2p's specs use to encode a PeerId, prefixing the hash bytes with a code identifying which hash function produced them.

The generation rule is deliberately cheap for small keys: a serialized public key of 42 bytes or fewer is embedded directly as an "identity" multihash, with no hashing at all; anything larger than 42 bytes gets hashed with SHA-256 before being wrapped in a multihash. Ed25519 support is mandatory for every implementation; RSA is a "should," kept mainly for interoperability with the IPFS DHT's existing bootstrap nodes; Secp256k1 and ECDSA are optional extras with patchier support.

A PeerId has two valid text representations: the legacy form is a bare multihash base58btc-encoded with no prefix (strings starting with Qm... for hashed keys, or 1... for identity-encoded Ed25519 keys); the newer form wraps the multihash in a CIDv1 using the libp2p-key multicodec, encoded in base32 (strings starting with bafz...). Implementations must be able to parse both. A PeerId embeds into a multiaddr (covered next) as a /p2p/<PeerId> segment; an older /ipfs/ prefix meant the same thing before the rename.

multiaddr: an address that describes itself

A traditional address, host:port, tells you nothing about which protocols to speak once you get there. libp2p's answer is multiaddr, a convention for encoding every layer of addressing information into a single, self-describing path. /ip4/198.51.100.0/tcp/4242 reads left to right as a set of instructions to follow: first, reach this IPv4 host; then, open this TCP port. Each individual segment is itself a valid multiaddr, and wrapping one inside another is called encapsulation; stripping a layer back off is decapsulation.

Because every layer is explicit, a single string can carry a peer's full dialable identity: /ip4/198.51.100.0/tcp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N says exactly which host, which port, and which PeerId to expect at the other end. Newer transports fold in cryptographic material the same way: a browser-facing WebRTC address looks like /ip4/1.2.3.4/udp/1234/webrtc/certhash/<hash>/p2p/<peer-id>, embedding a certificate hash so the browser can validate a self-signed certificate without a trusted CA. A relayed connection chains two PeerIds together: /ip4/198.51.100.0/tcp/4242/p2p/QmRelay/p2p-circuit/p2p/QmRelayedPeer.

The layers of a connection: transport, security, multiplexing

libp2p insists on being transport-agnostic: the choice of TCP, QUIC, WebSocket, WebRTC, or WebTransport is left to the application, and a single node can listen on several at once. Every connection, whatever the transport, passes through the same conceptual stack: a secure channel is negotiated, then (unless the transport already provides one) a stream multiplexer is layered on top.

QUIC is the transport that gets to skip a step. Standardized by the IETF as RFC 9000 (transport), RFC 9001 (TLS integration), and RFC 9002 (loss detection) after starting as a Google/Chrome experiment in 2014, QUIC bundles always-on encryption and native stream multiplexing into the transport itself. libp2p simply reuses both rather than layering its own security handshake and multiplexer on top. A self-signed certificate carries the PeerId, verified through the same handshake logic as libp2p's TLS security module, so establishing a libp2p connection over QUIC takes a single round trip. go-libp2p first implemented the pre-standard draft-29 (multiaddr code quic) and later added RFC 9000 (multiaddr code quic-v1); both are still distinguished in the wild.

Where the transport doesn't provide its own security, libp2p negotiates one of two secure-channel protocols:

Noise (/noise)TLS 1.3 (/tls/1.0.0)
HandshakeXX pattern, Noise_XX_25519_ChaChaPoly_SHA256Never older than TLS 1.3 (RFC 8446)
Peer authenticationStatic Noise keypair signed by the libp2p identity key, carried in the handshake payloadHost key embedded in a "libp2p Public Key Extension" inside a self-signed certificate
Client authN/A (mutual by construction)Server must require client certificates
Early negotiationCustom extension registry (no native Noise extension mechanism)Standard ALPN (RFC 7301)

Both protocols support early muxer negotiation: folding the choice of stream multiplexer into the security handshake payload itself, saving the extra round trip that a separate negotiation step would cost. As of today this optimization is implemented only in go-libp2p, and only for transports (TCP, WebSocket) that don't already provide native multiplexing the way QUIC does.

On the multiplexing side, libp2p supports two multiplexers: yamux (protocol ID /yamux/1.0.0), a Hashicorp-designed multiplexer with real flow control (a receiver can throttle a sender via an offset-based backpressure mechanism), and mplex, an earlier, simpler design with no flow control and no cap on the number of streams a peer can open. The specification is blunt about the trade-off: yamux should be preferred over mplex, and mplex is now in the process of being deprecated for anything but backward compatibility. Transports with native multiplexing (QUIC, WebTransport, WebRTC) skip this negotiation entirely.

That native multiplexing is also what makes WebTransport so much cheaper to connect over than plain WebSocket. A standard WebSocket-based libp2p connection stacks a TCP handshake (1 RTT), a TLS 1.3 handshake (1 RTT), the WebSocket upgrade (1 RTT), and then two more rounds of multistream negotiation plus the security handshake itself: six round trips before any application data moves. WebTransport, built on QUIC, collapses that to a QUIC handshake, a WebTransport handshake, and a single combined libp2p handshake (multistream plus Noise): three round trips total, half the cost.

StackRound trips before data flows
TCP + TLS 1.3 + WebSocket upgrade + multistream-select + security handshake6 RTT
QUIC + WebTransport handshake + libp2p handshake (multistream + Noise)3 RTT
QUIC only (native security and multiplexing, no extra negotiation)1 RTT

Agreeing on a protocol: multistream-select and identify

Once two peers share a secure, multiplexed connection, they still need to agree on which application protocol to speak over any given stream. That's multistream-select's job: a lightweight negotiation where the dialing side sends a /multistream/1.0.0 header followed by the protocol ID it wants (/ipfs/id/1.0.0, say); if the listener supports it, it echoes the ID straight back as acceptance, and if not, it replies na ("not available") and the dialer can retry with a fallback protocol ID. Every libp2p protocol identifies itself with a path-like, versioned string such as /my-app/amazing-protocol/1.0.1.

ProtocolProtocol ID
Ping/ipfs/ping/1.0.0
Identify/ipfs/id/1.0.0
Identify Push/ipfs/id/push/1.0.0
Noise/noise
TLS 1.3/tls/1.0.0
yamux/yamux/1.0.0
Circuit Relay v2 (hop)/libp2p/circuit/relay/0.2.0/hop
DCUtR/libp2p/dcutr
gossipsub v1.1/meshsub/1.1.0
Rendezvous/rendezvous/1.0.0
Kademlia DHTgenerally known as /ipfs/kad/1.0.0 (the go-libp2p-kad-dht implementation builds it from a configurable prefix plus /kad/1.0.0)

The first protocol most peers speak to each other is identify: an exchange of public key, listened-on addresses, and supported protocol IDs. Buried in that exchange is a field that turns out to matter enormously later: observedAddr, the address the responding peer actually saw the connection arrive from. A peer behind a NAT has no way to know its own public-facing address just by looking at its own network interfaces; observedAddr is how it finds out, by asking someone else what they saw. That single field is the seed that the entire NAT traversal stack below grows out of. A companion protocol, identify/push, lets a peer proactively broadcast an updated Identify message to everyone it knows, useful the moment it learns a new relay address or public address, so the rest of the network doesn't have to wait to re-ask.

Finding peers: Kademlia DHT, mDNS, rendezvous, bootstrap

Knowing a protocol ID doesn't help until you know who to open a stream with. libp2p treats discovering peers (letting others find you) and routing to peers (finding a specific target) as related but distinct problems, addressed by several interoperable mechanisms.

The primary one at internet scale is a DHT implementation of Kademlia, drawing on ideas from S/Kademlia, Coral, and BitTorrent's own DHT. Distance between two keys is XOR(sha256(key1), sha256(key2)) over a 256-bit SHA-256 key space; the routing table tries to keep k = 20 peers (the replication parameter) for each prefix-length bucket, and a single lookup fans out α = 10 requests concurrently. Nodes that are publicly reachable run in server mode, advertising the Kademlia protocol and accepting inbound streams; NAT-bound or resource-constrained nodes run in client mode, participating in lookups without ever being added to anyone else's routing table. A node bootstraps by periodically looking up random peer IDs (including its own) and folding whatever it discovers into its routing table.

For peers on the same local network, mDNS (RFC 6762) needs no configuration at all: a node broadcasts a query and any peer on the same segment answers with its multiaddr.

Rendezvous takes a different shape entirely: it's federated rather than distributed, meaning a single rendezvous point can become a bottleneck or single point of failure in a way the DHT and gossipsub deliberately avoid. Peers REGISTER themselves under a namespace at a rendezvous point, with a default registration lifetime of two hours and a hard ceiling of 72 hours, over protocol ID /rendezvous/1.0.0. It's commonly used to bootstrap discovery of circuit relays for browser nodes, or of subscribers to a pubsub topic.

Finally, plain bootstrap lists (a hardcoded or configured set of known peers to dial on startup) remain the simplest way to get a fresh node its first few connections into any of the above.

Dissemination: gossipsub v1.1

Discovery answers "who is out there"; pubsub answers "how do I tell everyone about something without a broadcast." libp2p's gossip protocol for this is gossipsub, whose overlay per topic maintains two kinds of peering: a sparse full-message mesh, sized to a target degree D = 6 (tolerated range 4–12), over which entire messages are forwarded, and a denser metadata-only peering used purely to gossip about which messages a peer has seen. A heartbeat every second drives mesh maintenance: grafting metadata peers into the full mesh, and pruning full-mesh peers back down to metadata-only when the mesh is oversubscribed. IHAVE announces recently seen message IDs; IWANT requests the ones a peer is actually missing. Publishing to a topic a node hasn't subscribed to falls back to fanout: six randomly chosen peers per topic remembered for delivery, forgotten if nothing gets published to that topic for two minutes.

Version 1.1 layered several security-motivated extensions onto that base design: explicit peering agreements for operators who want unconditional, always-on connections outside the scoring system; peer exchange (PX) during prune, offering the pruned peer alternative candidates instead of just dropping it; flood publishing, sending one's own messages to every sufficiently well-scored connected peer regardless of subscription, as a defense against eclipse attacks; adaptive gossip dissemination, tuned by a gossip factor of 0.25, whose three-round propagation leaves roughly a 58% (1 − (3/4)³) chance any given peer receives a gossip announcement of a new message; and outbound mesh quotas (D_out), which guarantee a minimum number of self-initiated, non-inbound connections in the mesh specifically to blunt Sybil attacks that try to dominate a victim's inbound slots.

Every peer scores every other peer locally (scores are never shared) from a weighted combination of per-topic behavior (time spent in the mesh, first-message deliveries, message delivery rate, invalid messages) and global behavior (IP co-location, explicit behavioral penalties). Falling below a GraylistThreshold gets a peer's messages ignored outright; a healthy mesh whose median score sags below an OpportunisticGraftThreshold triggers opportunistic grafting of at least two higher-scoring peers, checked roughly once a minute. That scoring system is what turns "everyone gossips with everyone" into something that survives adversarial peers, rather than something a single bad actor can quietly starve.

The NAT traversal stack

Most peers on the internet sit behind a NAT or a firewall, unreachable by an unsolicited inbound connection. This is the same fundamental problem NAT traversal and WebRTC solve with STUN and TURN. STUN and TURN work, but they work by leaning on dedicated, centrally operated servers: a STUN server exists purely to tell a client what its own public address looks like from outside, and a TURN server exists purely to relay traffic when a direct path can't be found. libp2p's stack solves the identical problem without standing up any dedicated infrastructure at all: every role is filled by ordinary peers already on the network.

The mapping is direct: where STUN tells you your own reflexive address, AutoNAT asks other libp2p peers to dial you back and report what they see. Where TURN relays your traffic as a last resort, Circuit Relay v2 has other peers volunteer as relays. And where ICE coordinates simultaneous connection attempts between two NATted peers, DCUtR ("decentralized hole punching," in the words of the peer-reviewed paper it grew out of) runs that same coordination over an existing relayed connection instead of a dedicated signaling server, requiring no prior knowledge of the network beyond being able to reach one bootstrap node.

AutoNAT: learning whether you're reachable

AutoNAT v1 (/libp2p/autonat/1.0.0) solves a simple but essential problem: a node has no built-in way to know whether it's publicly reachable or hidden behind a NAT. It asks other peers to dial it back, sending a DIAL message listing candidate addresses, receiving a DIAL_RESPONSE with a status such as OK, E_DIAL_ERROR, or E_DIAL_REFUSED. To prevent the protocol from being turned into an amplification attack, a peer performing the dial-back must only dial the address it observed the request arrive from, and must refuse requests that arrive over a relayed connection. Once more than three peers report a successful dial, a node can reasonably conclude it's public; more than three failures, and it concludes it's private.

AutoNAT v2 refines this in two ways. First, it verifies address by address rather than bundling a whole node's addresses into one verdict, useful because a node can have several addresses (different transports, different interfaces) with genuinely different reachability. Second, it adds a real verification mechanism: a nonce exchanged in a DialBack step lets the requester prove the dial-back peer really did connect to the claimed address, and it now permits dialing an address that differs from the observed source IP (something v1 forbade outright) by charging an amplification-resistant cost for the privilege, requiring the requester to first upload 30,000–100,000 bytes of filler data in 4,096-byte chunks before the dial-back proceeds.

Circuit Relay v2: relaying with limits

Circuit Relay v2 splits into two protocols: hop (/libp2p/circuit/relay/0.2.0/hop, between a client and the relay it wants to use) and stop (/libp2p/circuit/relay/0.2.0/stop, between the relay and the peer being reached). A client that expects to need relaying sends a RESERVE message over hop; an accepting relay returns a signed voucher, an expiration time, and optionally a duration and data cap on any connection it will carry. That reservation has to be refreshed before it expires, and it only holds while the client stays connected to the relay in the first place.

The limits matter because they encode a deliberate design decision. Circuit Relay v1 had no reservation system at all, which left relays open to being overloaded by however much traffic anyone chose to route through them. v2's mandatory reservations and optional duration/data caps turn relaying into a temporary bridge to get a hole-punch attempt started, not a permanent substitute for a direct connection, so the relay operator's bandwidth bill stays bounded no matter how the network grows. A full relayed multiaddr looks like /ip4/198.51.100.0/tcp/55555/p2p/QmRelay/p2p-circuit/p2p/QmAlice. Relays advertise their own address without the /p2p-circuit suffix, and clients append it themselves when constructing the relayed path to a specific peer.

DCUtR: punching a hole without a signaling server

With a relayed connection already up (itself secured end to end by the usual Noise/TLS handshake), DCUtR (/libp2p/dcutr) uses that connection as its own signaling channel to coordinate a simultaneous direct-dial attempt. The message exchange is deliberately small: a CONNECT message carries each side's observed and predicted addresses, and a SYNC message triggers the timed, simultaneous dial itself.

The timing trick is what makes the hole-punch land. The peer that received the inbound relayed connection (call it B) sends the first CONNECT and starts a timer; when A's CONNECT reply arrives, B stops the timer and now has a round-trip time measurement. B waits out half that RTT before sending SYNC (a rough estimate of one-way network delay), so that when A dials the moment it receives SYNC, and B dials the moment its RTT/2 timer expires, both dial attempts land at roughly the same instant, which is what a NAT's hole-punch actually depends on.

TCP and QUIC diverge from there. Over TCP, both sides open an outbound socket at their synchronized moment and rely on TCP simultaneous open to complete the handshake in one shot; for anything layered on top, A is treated as the client and B the server. Over QUIC, A still dials immediately on receiving SYNC, but B doesn't dial outright: it instead fires UDP packets full of random bytes at A's address every 10–200 milliseconds, just enough traffic to force open a NAT binding without attempting a real QUIC handshake through it, until A's real connection attempt gets through. If the first attempt fails, the inbound peer retries up to two more times (three attempts total).

Put together, the full sequence runs: Identify teaches each side its own observed address → AutoNAT tells it whether that address is actually reachable from outside → if private, it discovers a relay (typically via the DHT) and secures a Circuit Relay v2 reservation → an initial connection to the target rides over that relay → DCUtR runs its CONNECT/SYNC exchange over the relayed channel to fire a synchronized direct-dial attempt → success upgrades the connection to a direct path and drops the relay; failure leaves the relayed connection as the fallback.

Measuring it in the wild: the punchr campaign

Design intentions are one thing; Protocol Labs' measurement campaign (run between December 2022 and January 2023 using purpose-built tooling called punchr) is what tells us whether decentralized hole punching actually works at internet scale. The numbers come from roughly 4.4 million hole-punching attempts, spanning over 85,000 unique networks across 167 countries.

The headline figure: once a peer clears the earlier stages (getting a usable observed address, securing a relay reservation), the hole-punch itself succeeds 70% ± 7.1% of the time. That's a conditional number, though: around 29% of all attempts never even reach the hole-punching stage at all, dropping out earlier due to failures in address discovery or relay reservation. And when a hole punch does succeed, it's overwhelmingly a first-try success: 97.6% succeed on the very first attempt, with DCUtR's built-in retries accounting for only the remaining 2.4%.

TCP and QUIC turn out to have no statistically significant difference in raw success rate (both land around 70%), though QUIC shows less volatility specifically on IPv4, and when the two transports are raced against each other simultaneously, QUIC wins the race to establish a direct connection 81% of the time. Somewhat counterintuitively, round-trip time has no measurable bearing on outcome: the study found hole-punch success "doesn't depend on the round trip time," and IPv6 performed surprisingly poorly relative to IPv4, though the available data doesn't break that difference down further. Against the two prior academic benchmarks (a 2011 study reporting roughly 64% success, and a smaller 2005 study of 93 home NATs reporting 88%), this campaign's 70% comes from a dataset several orders of magnitude larger and far more geographically representative.

The one traversal case that reliably fails is a peer sitting behind a symmetric NAT, which maps each outbound connection to a different, effectively unpredictable external port. DCUtR's coordination protocol doesn't fundamentally break here; the real obstacle is that the port the far side would need to guess can't be predicted in advance. When hole punching fails, the connection simply keeps running over the Circuit Relay v2 path it was already using: the relay isn't just a bridge to a hole punch attempt, it's also the safety net for the cases hole punching can never reach.

libp2p as P2P's common substrate

Step back from any one layer and the pattern repeats: identity is a hashed public key rather than an address, addresses are self-describing rather than assumed, transports and security and multiplexing are all negotiated rather than fixed, and even the mechanism for getting through a NAT is built from ordinary peers rather than dedicated servers. None of these pieces is unique to libp2p: DHTs, gossip protocols, and NAT traversal each exist as their own well-studied fields. But bundling them behind a consistent, swappable module interface is what let a wire protocol built for one file-sharing project become the network layer underneath a blockchain's consensus clients, a storage network, and a browser transport, all without any of them needing to agree on anything more than which modules to plug in.

Back to top page