# How Iroh Hole Punching Works for Direct Peer-to-Peer Connections

> Discover how Iroh's hole punching establishes direct P2P connections. Learn about its NAT traversal techniques and relay fallback for reliable connections.

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

---

**Iroh uses a remote-state actor to monitor network changes, selects the lowest-ID client connection, and delegates UDP hole punching to the underlying noq library to establish direct P2P paths while falling back to relays when NAT traversal fails.**

Iroh is a Rust-based peer-to-peer networking library built on QUIC that automatically traverses NATs and firewalls to create direct connections. The hole punching mechanism is orchestrated 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 coordinates with the noq library to perform the actual UDP NAT traversal. This architecture allows applications to establish direct peer-to-peer connections without manual network configuration.

## When Hole Punching Triggers: The Remote-State Actor

The `RemoteStateActor` monitors local and remote address changes to determine when to initiate hole punching. The `trigger_holepunching` method (lines 504–517 in [`remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/remote_state.rs)) implements a sophisticated decision tree that prevents unnecessary attempts while ensuring connectivity.

### Selecting the Client Connection

Before punching, the actor must identify which connection should initiate the traversal. According to the source code, the logic filters for client-side connections and selects the one with the lowest `ConnId`:

```rust
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;
};

```

This selection strategy matters because only a client can initiate NAT traversal, and using the lowest ID provides deterministic behavior when both peers are clients.

### Gathering NAT Candidates

Once a connection is selected, the actor gathers potential paths through NATs. It retrieves remote candidates via `conn.get_remote_nat_traversal_addresses()` and combines them with local candidates from `self.state.local_candidates()`:

```rust
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();

```

The noq library probes the remote peer using STUN-like packets to discover these candidate IP:port pairs that may be reachable through NATs.

## The Decision Logic: Deduplication and Scheduling

Iroh avoids redundant hole punching attempts by comparing current candidates against the previous attempt. The `trigger_holepunching` method checks if the new remote candidates are a subset of the last attempt's candidates:

```rust
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 the address sets match, the actor schedules the next attempt using `HOLEPUNCH_ATTEMPTS_INTERVAL` (5 seconds by default) to conserve bandwidth and CPU:

```rust
if let Some(last_hp) = &self.state.last_holepunch {
    let next_hp = last_hp.when + HOLEPUNCH_ATTEMPTS_INTERVAL;
    if Instant::now() < next_hp {
        self.state.scheduled_holepunch = Some(next_hp);
        return;
    }
}

```

## Executing the Hole Punch via Noq

When new candidates are detected, `do_holepunching` (lines 560–575 in [`remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/remote_state.rs)) records the attempt and delegates the heavy lifting to the noq library. This method updates the state machine before calling the underlying connection:

```rust
impl State {
    fn do_holepunching(&mut self, conn: &noq::Connection) {
        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(),
        });

        conn.holepunch();
    }
}

```

The `conn.holepunch()` call triggers noq's implementation in [`noq_proto/src/n0_nat_traversal.rs`](https://github.com/n0-computer/iroh/blob/main/noq_proto/src/n0_nat_traversal.rs), which crafts and sends UDP packets to candidate address pairs from both sides until a direct path is established.

## Path Management and Selection

After successful hole punching, iroh updates the path state in [`iroh/src/socket/remote_map/remote_state/path_state.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/remote_map/remote_state/path_state.rs). Successful paths are recorded as `PathStatus::Open` with the source tagged as `Source::HolePunch` (lines 84–98):

```rust
self.state.paths.insert_open_path(addr, Source::HolePunch);
self.select_path();
self.apply_selected_path();

```

The `select_path()` method chooses the optimal path based on latency and reliability, while `apply_selected_path()` propagates this decision to all active connections. If a path later fails, the actor prunes it from the state and retries hole punching after the configured interval.

## Practical Usage Examples

Hole punching in iroh is automatic, but understanding the API helps developers optimize their applications.

### Connecting to a Remote Endpoint

When initiating a connection, iroh automatically triggers hole punching if direct UDP candidates are available:

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

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

#[tokio::main]
async fn main() -> iroh::Result<()> {
    let endpoint = Endpoint::builder(presets::N0)
        .secret_key(iroh::SecretKey::generate())
        .alpns(vec![ALPN.to_vec()])
        .relay_mode(RelayMode::Default)
        .bind()
        .await?;

    let remote = EndpointAddr::from_parts(
        remote_id,
        vec![
            TransportAddr::Ip("203.0.113.42:4567".parse()?),
            TransportAddr::Relay("https://relay.iroh.example/".parse()?),
        ],
    );

    let conn = endpoint.connect(remote, ALPN).await?;
    println!("Direct connection established (or relayed if NAT prevented it)");
    Ok(())
}

```

This example from [`iroh/examples/connect.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/connect.rs) demonstrates how the `Endpoint::connect` call implicitly handles NAT traversal by providing both UDP and relay addresses.

### Accepting Hole-Punched Connections

Listening endpoints automatically handle incoming hole punch attempts:

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

#[tokio::main]
async fn main() -> iroh::Result<()> {
    let endpoint = Endpoint::builder(presets::N0)
        .secret_key(iroh::SecretKey::generate())
        .relay_mode(RelayMode::Default)
        .bind()
        .await?;

    let inbound = endpoint.accept().await?.await?;
    println!("Received direct connection from {}", inbound.remote_id());
    Ok(())
}

```

As shown in [`iroh/examples/listen.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/listen.rs), the endpoint advertises its UDP ports and processes inbound hole punch requests without requiring application-level code.

## Summary

- **Automatic Triggering**: The `RemoteStateActor` in [`remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/remote_state.rs) monitors address changes and triggers `trigger_holepunching` only when new NAT candidates appear or after `HOLEPUNCH_ATTEMPTS_INTERVAL` (5 seconds).
- **Client Selection**: The system selects the client-side connection with the lowest `ConnId` to ensure deterministic NAT traversal initiation.
- **Noq Delegation**: Actual UDP hole punching is delegated to `conn.holepunch()` in the noq library, which performs the STUN-like packet exchange.
- **Path Lifecycle**: Successful attempts create `PathStatus::Open` entries in [`path_state.rs`](https://github.com/n0-computer/iroh/blob/main/path_state.rs), which the path selector uses to route traffic over the fastest direct connection.
- **Fallback Support**: If hole punching fails, iroh automatically falls back to relay servers specified in the `EndpointAddr`.

## Frequently Asked Questions

### How does iroh decide when to attempt hole punching?

Iroh's `RemoteStateActor` compares current local and remote NAT candidates against the previous attempt stored in `last_holepunch`. If the candidate sets have changed or `HOLEPUNCH_ATTEMPTS_INTERVAL` (5 seconds) has elapsed since the last attempt, it triggers a new hole punch via `trigger_holepunching`. This prevents redundant traffic when network topology is stable.

### What happens if hole punching fails?

If the noq library cannot establish a direct UDP path after attempting all candidate pairs, iroh falls back to using the relay server specified in the `EndpointAddr`. The connection remains functional over the relay while the remote-state actor continues to monitor for new candidates and retry hole punching in the background.

### Why does iroh select the connection with the lowest ConnId?

The hole punching logic requires a client-side connection to initiate NAT traversal. When multiple connections exist, selecting the one with the smallest `ConnId` provides deterministic behavior, particularly in scenarios where both peers are clients. This ensures both sides don't simultaneously initiate redundant hole punch attempts.

### Can I disable hole punching in iroh?

While you cannot explicitly disable hole punching in the public API, you can force relay-only connections by providing only `TransportAddr::Relay` addresses in the `EndpointAddr`. However, the default `RelayMode::Default` configuration automatically enables hole punching alongside relay support to prioritize direct P2P connections when possible.