# How Iroh Handles Network Changes and Path Selection: A Deep Dive into RemoteStateActor

> Discover how Iroh expertly manages network changes and path selection using RemoteStateActor. Learn about its efficient loss detection, hole-punching, and optimal path selection methods.

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

---

**Iroh detects network changes through the `RemoteStateActor`, which immediately pings all active paths to provoke loss detection and triggers hole-punching to discover fresh NAT-traversal candidates, then selects the optimal path using a configurable `PathSelector` trait that biases selection based on round-trip time and address type.**

Iroh maintains resilient peer connections by dynamically adapting to network volatility. When underlying network conditions change—such as Wi-Fi roaming, IP address changes, or interface switches—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) orchestrates the detection, probing, and re-selection logic to ensure uninterrupted connectivity.

## Detecting Network Changes with the RemoteStateActor

When the operating system notifies Iroh of a network change, the actor receives a `RemoteStateMessage::NetworkChange { is_major }` message. The handler at `handle_msg_network_change` (lines 5253‑5270) executes a two-phase recovery process to assess path viability and discover new routes.

### Immediate Path Probing

The first step forces rapid failure detection across all existing connections. The actor iterates through every active connection and pings each known path using the underlying QUIC (`noq`) stack:

```rust
fn handle_msg_network_change(&mut self, is_major: bool) {
    // 1️⃣ Ping all the paths so loss‑detection starts ASAP.
    for conn in self.connections.values() {
        if let Some(noq_conn) = conn.handle.upgrade() {
            for (path_id, addr) in &conn.paths {
                if let Some(path) = noq_conn.path(*path_id) {
                    // Ping the current path
                    if let Err(err) = path.ping() {
                        warn!(%err, %path_id, ?addr, "failed to ping path");
                    }
                }
            }
        }
    }

    // 2️⃣ On a *major* network change, force a new hole‑punch round.
    if is_major {
        self.trigger_holepunching();
    }
}

```

This ping operation (lines 5253‑5270) forces the QUIC layer to notice broken links immediately rather than waiting for standard timeouts.

### Triggering Hole-Punching on Major Changes

For **major** network changes—such as switching from Wi-Fi to cellular—the actor unconditionally invokes `trigger_holepunching()` (lines 5271‑5272). This initiates a NAT-traversal round via `do_holepunching` to discover fresh public addresses on both sides. Even minor changes may trigger hole-punching in subsequent logic to ensure the candidate address pool remains current.

## The Path Selection Mechanism

After path discovery completes or when connection states change, Iroh executes the `select_path` method (around line 6455) to choose the optimal route from available candidates.

### PathSelectionContext and the PathSelector Trait

The selection logic relies on the `PathSelector` trait, defined in [`iroh/src/socket/path_selector.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/path_selector.rs). The core selection flow builds a context object and delegates the decision:

```rust
fn select_path(&mut self) {
    let current_path = self.state.selected_path.as_ref();
    let selected_addr = {
        // ① Build a context describing the current situation.
        let ctx = PathSelectionContext::new(current_path, &self.connections);
        // ② Ask the configured PathSelector for the best address.
        self.state.path_selector.select(&ctx).selected().cloned()
    };

    // ③ If the selector picks a different address, store it and emit an event.
    if let Some(addr) = selected_addr && self.state.selected_path.as_ref() != Some(&addr) {
        let prev_remote = self.state.selected_path.replace(addr.clone());
        event!(target: "iroh::_events::path::selected", Level::DEBUG,
               remote = %self.state.endpoint_id.fmt_short(),
               network_path = %addr,
               prev_network_path = %prev_remote.map(|p| format!("{p}")).unwrap_or("None".to_string()));
    } else {
        trace!(?current_path, "keeping current path");
    }

    // ④ Apply the new selection to every connection.
    self.apply_selected_path();
}

```

The `PathSelectionContext` carries the current selected path and a snapshot of all connections, allowing selectors to inspect per-connection latency, path type (direct IP, relay, or custom transport), and recent history.

### The BiasedRttPathSelector Default Strategy

Iroh’s default implementation, `BiasedRttPathSelector` (located in [`iroh/src/socket/biased_rtt_path_selector.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/biased_rtt_path_selector.rs)), applies a latency-biased selection algorithm:

1. **Gather candidates** from `RemotePathState`, including addresses discovered via hole-punching, direct discovery, or address lookup.
2. **Measure RTT** for each IP-type path via QUIC statistics (`path_stats.rtt`).
3. **Apply bias**: Direct IP paths with RTT ≤ `GOOD_ENOUGH_LATENCY` (approximately 10 ms) are preferred. If no direct path meets the threshold, the selector falls back to relay or custom transport paths.

Once the selector returns a `FourTuple` representing the best address, `apply_selected_path()` opens that path on every connection and marks redundant paths as *Backup* or closes them.

## Implementing Custom Path Selection Logic

You can replace the default `BiasedRttPathSelector` with custom logic by implementing the `PathSelector` trait. For example, to always prefer a specific relay regardless of latency:

```rust
use iroh::socket::{PathSelector, PathSelectionContext};

struct RelayFirstSelector;

impl PathSelector for RelayFirstSelector {
    fn select(&self, ctx: &PathSelectionContext) -> SelectedPath {
        // Custom logic: pick the first relay address, ignore RTT.
        let relay = ctx
            .candidates()
            .find(|addr| addr.is_relay())
            .cloned()
            .expect("no relay address available");
        SelectedPath::new(Some(relay))
    }
}

// When creating the socket:
let selector = Arc::new(RelayFirstSelector);
let socket = Socket::new(..., selector)?;

```

Injecting the custom selector via `Arc<dyn PathSelector>` ensures Iroh uses your strategy instead of the default RTT-based selection.

## Summary

- **Network change detection** occurs via `RemoteStateMessage::NetworkChange` handled by `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).
- **Immediate recovery** involves pinging all paths (lines 5253‑5270) to trigger rapid loss detection.
- **Hole-punching** is triggered on major changes via `trigger_holepunching()` to discover fresh NAT-traversal candidates.
- **Path selection** uses the `PathSelector` trait with `PathSelectionContext`, defaulting to `BiasedRttPathSelector` which prefers low-latency direct paths (≤10 ms) over relays.
- **Application** of selected paths occurs via `apply_selected_path()`, which synchronizes the choice across all connections.

## Frequently Asked Questions

### How does Iroh detect when a network interface changes?

Iroh receives OS-level network notifications and translates them into `RemoteStateMessage::NetworkChange` messages sent to the `RemoteStateActor`. The `handle_msg_network_change` method then pings all existing paths to force immediate failure detection and triggers hole-punching if the change is classified as major (lines 5253‑5272).

### What is the default criteria for selecting a path in Iroh?

The default `BiasedRttPathSelector` selects paths based on measured RTT from QUIC statistics, preferring direct IP paths with latency at or below `GOOD_ENOUGH_LATENCY` (approximately 10 milliseconds). If no direct path meets this threshold, it falls back to relay or custom transport paths according to the implementation in [`iroh/src/socket/biased_rtt_path_selector.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/biased_rtt_path_selector.rs).

### Can I customize how Iroh chooses between direct and relay connections?

Yes, by implementing the `PathSelector` trait and providing your implementation when constructing the socket. The trait receives a `PathSelectionContext` containing all candidate addresses and connection states, allowing you to implement custom logic such as always preferring relays, prioritizing specific network interfaces, or using custom latency thresholds.

### What happens to existing connections when the network changes?

When a network change occurs, the `RemoteStateActor` immediately pings all paths to accelerate loss detection. After hole-punching discovers new candidates, `select_path()` evaluates all options and `apply_selected_path()` migrates connections to the newly selected optimal path, marking obsolete paths as backup or closing them to prevent resource leakage.