# How Does Iroh Handle Hole-Punching for Direct Peer-to-Peer Connections?

> Discover how Iroh automates hole-punching for direct peer-to-peer connections. Learn about its remote-state actor, NAT traversal, and efficient connection management.

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

---

**Iroh automates hole-punching through a remote-state actor that monitors address changes, selects the lowest-ID client connection, and delegates UDP NAT traversal to the noq library, establishing direct paths while scheduling retries to avoid unnecessary traffic.**

Iroh is a peer-to-peer networking library built on QUIC that solves NAT traversal to create direct connections between devices behind firewalls. 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 address discovery, connection selection, and path management. This article examines the complete flow from trigger conditions to successful path establishment.

## Architecture Overview

Iroh's P2P communication relies on **QUIC** via the *noq* library, with hole-punching logic centralized in the **remote-state actor**. This actor monitors changes in local and remote candidate addresses, determines when new hole-punch attempts are necessary, and delegates the actual NAT traversal to the underlying connection.

The architecture follows an actor-based model where:
- The remote-state actor manages connection lifecycle and address state
- The noq library handles low-level UDP packet exchange
- Path states track the viability of network routes

## When Hole-Punching Is Triggered

The `trigger_holepunching` method 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) (lines 504-517) implements the decision logic for initiating NAT traversal.

### Connection Selection Criteria

The actor first validates that connections exist and selects the **client-side connection with the lowest `ConnId`**. Only clients can initiate NAT traversal, and the deterministic selection prevents conflicts when both peers are clients.

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

```

### Candidate Address Discovery

The actor gathers candidate addresses from both local and remote sides:

1. **Remote candidates**: Obtained via `conn.get_remote_nat_traversal_addresses()`, which queries the noq library's STUN-like probing mechanism
2. **Local candidates**: Retrieved via `self.state.local_candidates()`

### Debouncing and Scheduling

To prevent redundant attempts, the actor compares current candidates against the previous attempt:

```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 no new addresses are discovered, the next attempt is scheduled after `HOLEPUNCH_ATTEMPTS_INTERVAL` (5 seconds), conserving bandwidth and CPU resources when the network topology is stable.

## Executing NAT Traversal

When new candidates are detected, the actor calls `do_holepunching`, which delegates to the noq connection.

### The do_holepunching Method

Located 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) (approximately lines 560-575), this method records the attempt and triggers the underlying traversal:

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

        // Delegate to noq for UDP hole-punching
        conn.holepunch();
    }
}

```

### UDP Hole-Punch Implementation

The heavy lifting is performed by the *noq* crate (`noq_proto::n0_nat_traversal`), which implements the actual **UDP hole-punch** algorithm. This involves both peers sending UDP packets to candidate address pairs until a path is established, effectively piercing symmetric NATs.

## Path Management and Selection

Once traversal succeeds, the actor updates the **remote 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).

### Path State Tracking

Successful hole-punched paths are inserted as `PathStatus::Open`:

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

```

This status enables the path for traffic routing and emits the result to pending resolve requests.

### Path Selection and Failover

The path selector evaluates all open paths and selects the optimal route (typically lowest latency). The actor then propagates this selection to all connections:

```rust
self.select_path();               // picks the fastest, most reliable path
self.apply_selected_path();       // tells every connection which path to use

```

If a path later fails, the actor prunes it from the state and triggers a new hole-punch attempt after the configured interval.

## Implementation Examples

### Connecting to a Remote Endpoint

Hole-punching occurs automatically when using `Endpoint::connect`. The following example from [`iroh/examples/connect.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/connect.rs) demonstrates the minimal setup:

```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<()> {
    // Build an endpoint with 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?;

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

    // Connect triggers automatic hole-punching
    let conn = endpoint.connect(remote, EXAMPLE_ALPN).await?;
    println!("Direct connection established");
    Ok(())
}

```

### Accepting Inbound Connections

Listening endpoints automatically handle incoming hole-punch attempts, as shown in [`iroh/examples/listen.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/listen.rs):

```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?;

    // Accept handles hole-punch requests automatically
    let inbound = endpoint.accept().await?.await?;
    println!("Received direct connection from {}", inbound.remote_id());
    Ok(())
}

```

## Summary

- **Automatic Triggering**: The `RemoteStateActor` monitors candidate addresses in [`remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/remote_state.rs) and initiates hole-punching when the network topology changes.
- **Deterministic Selection**: The system selects the client connection with the lowest `ConnId` to ensure consistent behavior during simultaneous connection attempts.
- **Efficient Scheduling**: A 5-second debounce interval (`HOLEPUNCH_ATTEMPTS_INTERVAL`) prevents redundant attempts when addresses remain stable.
- **Delegated Execution**: The actual UDP NAT traversal is handled by the noq library's `conn.holepunch()` method, which implements STUN-like probing.
- **Lifecycle Management**: Successful paths are tracked as `PathStatus::Open` in [`path_state.rs`](https://github.com/n0-computer/iroh/blob/main/path_state.rs), with automatic failover and retry logic.

## Frequently Asked Questions

### What happens if hole-punching fails?

If UDP hole-punching fails to establish a direct path, iroh automatically falls back to relay servers configured via `RelayMode::Default`. The connection remains functional through the relay while the actor continues to attempt direct connection establishment in the background.

### How does iroh select which connection to use for hole-punching?

According to the source code in [`remote_state.rs`](https://github.com/n0-computer/iroh/blob/main/remote_state.rs), iroh filters for client-side connections using `conn.side().is_client()` and selects the one with the lowest `ConnId`. This deterministic approach ensures that when both peers attempt to connect simultaneously, they agree on which side initiates the NAT traversal.

### What is the interval between hole-punch attempts?

Iroh uses a fixed interval of 5 seconds (`HOLEPUNCH_ATTEMPTS_INTERVAL`) between attempts when no new candidate addresses are discovered. This prevents unnecessary network traffic and CPU usage while maintaining responsiveness to network changes.

### How does iroh handle symmetric NATs?

The underlying noq library (`noq_proto::n0_nat_traversal`) implements a UDP hole-punching algorithm that sends packets from both sides to candidate address pairs. While symmetric NATs present challenges due to port remapping, the coordinated packet exchange from both peers maximizes the probability of establishing a direct path through the temporary mapping windows.