# How Iroh Hole-Punching Works for Direct Peer Connections

> Discover how Iroh enables direct peer connections through advanced UDP NAT traversal and path status management. Learn the technical details of Iroh hole-punching for efficient P2P communication.

- Repository: [number zero/iroh](https://github.com/n0-computer/iroh)
- Tags: deep-dive
- Published: 2026-07-09

---

**Iroh establishes direct peer-to-peer connections by using a remote-state actor to monitor network address changes, selecting the lowest-ID client connection, and delegating UDP NAT traversal to the underlying noq library, while tracking successful paths as `PathStatus::Open` in the path state manager.**

Iroh is a peer-to-peer networking library that establishes direct QUIC connections through NATs and firewalls using automatic hole-punching. The implementation relies on a **remote-state actor** in [`iroh/src/socket/remote_map/remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/remote_map/remote_state.rs) to coordinate when and how to punch holes, leveraging the noq crate for low-level UDP traversal. This article examines the source code to explain how iroh hole-punching detects candidate addresses, triggers traversal attempts, and manages successful direct paths.

## The Remote State Actor Architecture

The core of iroh's hole-punching logic lives in the **RemoteStateActor**, which runs inside [`iroh/src/socket/remote_map/remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/remote_map/remote_state.rs). This actor watches for changes in local and remote candidate addresses, decides when a new hole-punch attempt is required, and asks the underlying noq connection to perform the actual NAT traversal.

The actor maintains the state of all connections to a given remote endpoint, tracking which paths are open, which are candidates for hole-punching, and when the last attempt occurred. It ensures that hole-punching only happens when necessary, preventing unnecessary network traffic when the topology is stable.

## When and How Hole-Punching is Triggered

The `trigger_holepunching` method (lines 504–517 in [`remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/remote_state.rs)) implements a five-step decision process to determine whether to initiate NAT traversal.

### The Decision Logic

The method first checks if any connections exist, returning early if the set is empty. It then selects the **client connection with the lowest `ConnId`**, as only client-side connections can initiate NAT traversal, and the deterministic selection prevents both peers from acting simultaneously when both are clients.

Next, it gathers candidate addresses by calling `conn.get_remote_nat_traversal_addresses()`, which returns IP:port pairs discovered via STUN-like probes. These are compared against the previous attempt's candidates; if the sets are unchanged and the interval `HOLEPUNCH_ATTEMPTS_INTERVAL` (5 seconds) has not elapsed, the attempt is deferred to save bandwidth.

```rust
/// Triggers hole‑punching to the remote endpoint.
fn trigger_holepunching(&mut self) {
    // 1️⃣ No connections → nothing to do.
    if self.connections.is_empty() {
        trace!("not holepunching: no connections");
        return;
    }

    // 2️⃣ Pick the *client* connection with the lowest ConnId.
    let Some(conn) = self.connections.iter()
        .filter_map(|(id, state)| state.handle.upgrade().map(|c| (*id, c)))
        .filter(|(_, conn)| conn.side().is_client())
        .min_by_key(|(id, _)| *id)
        .map(|(_, conn)| conn) else {
        trace!("not holepunching: no client connection");
        return;
    };

    // 3️⃣ Gather candidate addresses from the remote side.
    let remote_candidates = match conn.get_remote_nat_traversal_addresses() {
        Ok(addrs) => BTreeSet::from_iter(addrs),
        Err(err) => {
            warn!("failed to get nat candidate addresses: {err:#}");
            return;
        }
    };
    let local_candidates = self.state.local_candidates();

    // 4️⃣ If the address set changed since the last attempt ⇒ hole‑punch now,
    //    otherwise schedule the next attempt after a fixed interval.
    let new_candidates = self.state.last_holepunch.as_ref().map(|last_hp| {
        !remote_candidates.is_subset(&last_hp.remote_candidates)
            || !local_candidates.is_subset(&last_hp.local_candidates)
    }).unwrap_or(true);

    if !new_candidates {
        if let Some(last_hp) = &self.state.last_holepunch {
            let next_hp = last_hp.when + HOLEPUNCH_ATTEMPTS_INTERVAL;
            if Instant::now() < next_hp {
                trace!(scheduled_in = ?(next_hp - Instant::now()),
                       "not holepunching: no new addresses");
                self.state.scheduled_holepunch = Some(next_hp);
                return;
            }
        }
    }

    // 5️⃣ Finally, delegate the actual NAT‑traversal to the connection.
    self.state.do_holepunching(conn);
}

```

**Key implementation details:**
- **Client selection**: The filter `conn.side().is_client()` ensures only clients initiate, preventing redundant attempts from both sides.
- **Candidate deduplication**: The `new_candidates` check uses set subset operations to detect topology changes.
- **Scheduling**: Failed or unchanged attempts are deferred by `HOLEPUNCH_ATTEMPTS_INTERVAL`, defaulting to 5 seconds.

## Executing NAT Traversal

Once triggered, the actor calls `do_holepunching` (lines 560–575 in [`remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/remote_state.rs)), which records the attempt and delegates to the noq library.

```rust
impl State {
    fn do_holepunching(&mut self, conn: &noq::Connection) {
        // Record the attempt for future comparison.
        self.last_holepunch = Some(HolepunchAttempt {
            when: Instant::now(),
            local_candidates: self.local_candidates(),
            remote_candidates: conn
                .get_remote_nat_traversal_addresses()
                .unwrap_or_default()
                .into_iter()
                .collect(),
        });

        // The *noq* connection knows how to craft and send the required
        // STUN‑like packets to punch a hole through symmetric NATs.
        conn.holepunch();
    }
}

```

The **noq** crate (`noq_proto::n0_nat_traversal`) handles the low-level UDP hole-punch algorithm, sending packets from both sides to candidate address pairs until a path is established. This separation of concerns allows iroh to focus on state management while noq handles protocol specifics.

## Path Management and Selection

After successful hole-punching, the resulting path is tracked in [`path_state.rs`](https://github.com/n0-computer/iroh/blob/main/path_state.rs) (lines 84–98). The actor inserts the path as `PathStatus::Open`, marks its source as `Source::HolePunch`, and invokes the path selector.

```rust
self.state.paths.insert_open_path(addr, Source::HolePunch);
self.select_path();               // picks the fastest, most reliable path
self.apply_selected_path();       // tells every connection which path to use

```

The **path selector** evaluates all open paths (including relayed and direct) and chooses the optimal one based on latency and reliability. If a hole-punched path later fails, the actor prunes it from the state and schedules a new hole-punch attempt after the interval expires.

## Practical Implementation Examples

### Connecting to a Remote Peer

The [`connect.rs`](https://github.com/n0-computer/iroh/blob/main/connect.rs) example demonstrates how iroh automatically triggers hole-punching when calling `endpoint.connect()`:

```rust
use iroh::{Endpoint, EndpointAddr, RelayMode, TransportAddr, endpoint::presets};

const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";

#[tokio::main]
async fn main() -> iroh::Result<()> {
    // 1️⃣ Build an endpoint that uses the default relay servers.
    let endpoint = Endpoint::builder(presets::N0)
        .secret_key(iroh::SecretKey::generate())
        .alpns(vec![EXAMPLE_ALPN.to_vec()])
        .relay_mode(RelayMode::Default)          // <‑‑ enables hole‑punching & relaying
        .bind()
        .await?;

    // 2️⃣ Build the remote address (public key + UDP candidates + relay URL).
    let remote = EndpointAddr::from_parts(
        /* remote endpoint id */ remote_id,
        vec![
            TransportAddr::Ip("203.0.113.42:4567".parse()?),
            TransportAddr::Relay("https://relay.iroh.example/".parse()?),
        ],
    );

    // 3️⃣ Connect – iroh will trigger hole‑punching behind the scenes.
    let conn = endpoint.connect(remote, EXAMPLE_ALPN).await?;
    println!("🔗 Direct connection established (or relayed if NAT prevented it)");
    // … use `conn.open_bi()` etc.
    Ok(())
}

```

*Source*: [`iroh/examples/connect.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/connect.rs) (lines 41–53 for builder, 71–78 for address creation, 80–83 for connect).

### Accepting Inbound Connections

The [`listen.rs`](https://github.com/n0-computer/iroh/blob/main/listen.rs) example shows how an endpoint advertises its UDP ports and accepts inbound hole-punches:

```rust
use iroh::{Endpoint, endpoint::presets};

#[tokio::main]
async fn main() -> iroh::Result<()> {
    // Listen with the same preset – the library will advertise its UDP ports
    // and accept inbound hole‑punches from peers.
    let endpoint = Endpoint::builder(presets::N0)
        .secret_key(iroh::SecretKey::generate())
        .relay_mode(RelayMode::Default)
        .bind()
        .await?;

    // Wait for an inbound connection.
    let inbound = endpoint.accept().await?.await?;
    println!("🛜 Received direct (or relayed) connection from {}", inbound.remote_id());
    Ok(())
}

```

*Source*: [`iroh/examples/listen.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/listen.rs).

## Summary

- **Iroh hole-punching** is coordinated by the `RemoteStateActor` in [`iroh/src/socket/remote_map/remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/remote_map/remote_state.rs), which monitors address changes and decides when to initiate NAT traversal.
- **Client selection** uses the connection with the lowest `ConnId` to ensure deterministic behavior when both peers are clients.
- **Candidate deduplication** prevents redundant attempts by comparing current local and remote addresses against the last `HolepunchAttempt`, deferring retries by `HOLEPUNCH_ATTEMPTS_INTERVAL` (5 seconds).
- **Execution** delegates to the noq library via `conn.holepunch()`, which implements the low-level UDP packet exchange.
- **Path management** stores successful traversals as `PathStatus::Open` in [`path_state.rs`](https://github.com/n0-computer/iroh/blob/main/path_state.rs), with the path selector choosing the best available route for subsequent traffic.

## Frequently Asked Questions

### When does iroh trigger hole-punching?

Iroh triggers hole-punching when the `RemoteStateActor` detects changes in local or remote candidate addresses, or when the `HOLEPUNCH_ATTEMPTS_INTERVAL` has elapsed since the last attempt. The `trigger_holepunching` method checks if new addresses are subsets of previous candidates, initiating traversal only when the network topology has changed or the retry timer expires.

### Why does iroh select the client connection with the lowest ID?

The actor filters for client-side connections using `conn.side().is_client()` and selects the one with the minimum `ConnId` to ensure deterministic behavior. This prevents both peers from simultaneously initiating hole-punching when both happen to be clients, reducing race conditions and unnecessary network traffic.

### What happens if hole-punching fails?

If NAT traversal fails, the connection falls back to relay servers specified in the `EndpointAddr`. The actor continues to monitor for address changes and will retry hole-punching after `HOLEPUNCH_ATTEMPTS_INTERVAL` (5 seconds) if new candidates become available, ensuring the system eventually establishes a direct path if the network topology permits.

### How does iroh handle multiple candidate addresses?

The actor collects all candidate addresses from `get_remote_nat_traversal_addresses()` and stores them in a `BTreeSet`. It compares these sets against previous attempts to detect changes. When executing `do_holepunching`, the noq library attempts to establish connectivity with each candidate pair, and the first successful path is marked as `PathStatus::Open` in the path state manager.