How Iroh Handles Connection Failures and Automatically Falls Back to Relays

Iroh maintains two independent transport layers—a direct QUIC path and a persistent relay connection—to ensure seamless failover when direct peer-to-peer connections fail, automatically switching traffic to relays without application intervention.

Iroh, the open-source peer-to-peer networking library from n0-computer, implements sophisticated connection failure handling that keeps communications alive even when NAT traversal fails or network links drop. By architecting every peer connection with dual transport paths, the library ensures that applications remain connected through automatic relay fallback when direct UDP hole-punching becomes impossible.

The Dual Transport Architecture

Every connection in iroh is built on top of two independent transport layers that operate simultaneously:

  • Direct QUIC path: Attempts to establish peer-to-peer UDP connections after NAT traversal, implemented in iroh/src/endpoint/connection.rs. The connect handshake creates a noq::Connecting and later registers the resulting noq::Connection via conn_from_noq_conn.

  • Relay path: Maintains a persistent QUIC connection to a relay server that can forward datagrams when direct communication fails, managed in iroh/src/socket/transports/relay/actor.rs. The RelayActor owns one or more ActiveRelayActor instances, each maintaining an iroh_relay::client::Client to a specific RelayUrl.

When an endpoint is constructed, it receives a RelayMode (Default, Staging, Custom, or Disabled) that determines which relay servers to use. In iroh/src/endpoint.rs (lines 153-167), this mode converts into a TransportConfig:

// iroh/src/endpoint.rs – TransportConfig conversion
impl From<RelayMode> for Option<TransportConfig> {
    fn from(mode: RelayMode) -> Self {
        match mode {
            RelayMode::Disabled => None,
            RelayMode::Default => Some(TransportConfig::Relay {
                urls: crate::defaults::prod::default_relay_map(),
            }),
            RelayMode::Staging => Some(TransportConfig::Relay {
                urls: crate::defaults::staging::default_relay_map(),
            }),
            RelayMode::Custom(map) => Some(TransportConfig::Relay { urls: map.clone() }),
        }
    }
}

Address Resolution and Connection Ordering

When Endpoint::connect is invoked, the endpoint builds an EndpointAddr containing both direct IP addresses (from the address-lookup service) and relay addresses (TransportAddr::Relay(url)).

The connection logic in iroh/src/endpoint/connection.rs (around line 1510) orders these addresses with direct transports prioritized over relays:

// iroh/src/endpoint/connection.rs – address ordering
let mut addrs = ep_addr.into_iter().collect::<Vec<_>>();
// Custom (direct) transports are primary, relay transports are backup

The Connecting future attempts each address sequentially. If the direct path fails to establish a QUIC handshake, the implementation automatically proceeds to the next address in the list. Because the relay address is always present (unless RelayMode::Disabled is configured), the connection seamlessly falls back to the relay without requiring additional user code or error handling.

The Relay Actor: Keeping Fallback Paths Alive

Even while attempting direct connections, the relay path remains active through the RelayActor system. When the endpoint initializes, RelayActor::new launches an ActiveRelayActor for each configured relay URL.

The ActiveRelayActor::run_once method executes a continuous loop that:

  1. Dials the relay server with exponential back-off if the initial connection fails
  2. Maintains the QUIC session with keep-alive traffic
  3. Re-sends any datagrams that could not be delivered during connection interruptions (see run_once lines 363-373)
  4. Updates the shared RelayStatus via set_status(&self.url, RelayConnectionState::Connected/Disconnected) to inform the socket layer of availability

If the relay connection drops, the ActiveRelayActor automatically restarts it, guaranteeing that a fallback path is always ready when needed.

Detecting Failures and Switching Paths

The socket layer maintains a PathList per connection accessible via Connection::paths(). This structure tracks both the direct QUIC path and the relay path simultaneously.

Applications can monitor transport changes through the PathEvent stream obtained from Connection::path_events(). This notifies callers when:

  • A new direct path becomes selected (hole-punching succeeded)
  • The selected path falls back to the relay (direct path timeout or link failure)
  • A path is closed (permanent failure)

When the direct path reports a ConnectionError or the underlying socket detects loss of the selected path, the PathList removes the failing entry. The relay path remains active in the list, ensuring the connection stays usable while the system attempts recovery.

The automatic failover behavior is validated in iroh/tests/patchbay.rs, which simulates network infrastructure failures. In the link-outage test (lines 188-191), the endpoint first establishes a hole-punched direct connection, then the physical interface is taken down:

// iroh/tests/patchbay.rs – link-outage recovery (excerpt)
dev.iface("eth0").unwrap().link_down().await?;
tokio::time::sleep(downtime).await;
dev.iface("eth0").unwrap().link_up().await?;   // link restored
ping_open(&conn, timeout).await?;             // works via relay while link is down
conn.wait_ip(timeout).await?;                  // waits for a new direct path after link up

During the outage, ping_open continues to succeed via the relay. When the link returns, conn.wait_ip detects the new direct path, and the endpoint automatically switches back to peer-to-peer communication while maintaining the relay as a backup for future failures.

Configuring Relay Modes

Developers control relay behavior through the RelayMode enum:

  • RelayMode::Default: Uses the production relay map for maximum connectivity
  • RelayMode::Staging: Connects to staging relay servers for testing
  • RelayMode::Custom(map): Specifies custom relay URLs for private infrastructure
  • RelayMode::Disabled: Removes relay fallback entirely (connections fail if direct paths unavailable)

Practical Implementation

The following example demonstrates building an endpoint with automatic relay fallback:

use iroh::endpoint::{Endpoint, RelayMode};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Build an endpoint that uses the production relay map by default.
    let ep = Endpoint::builder()
        .relay_mode(RelayMode::Default) // <-- adds a persistent relay path
        .bind()
        .await?;

    // `addr` may contain both direct IPs (from the address-lookup) and a relay URL.
    let conn = ep.connect(addr, b"my/alpn").await?; // tries direct first, falls back to relay

    // The connection stays alive even if the direct path drops.
    // You can observe the current path via `conn.paths()` or listen for changes:
    let mut events = conn.path_events();
    while let Some(event) = events.next().await {
        println!("Path event: {event:?}");
    }

    Ok(())
}

In this implementation, the RelayActor continuously maintains the relay connection in the background. If direct establishment fails due to symmetric NATs or firewall rules, the connection succeeds via relay and automatically migrates to direct communication once hole-punching becomes possible.

Summary

  • Iroh creates parallel transport layers for every connection, maintaining both direct QUIC paths and persistent relay connections simultaneously.
  • Address ordering prioritizes direct communication but includes relay URLs as automatic fallback options when connect iterates through available transports.
  • The RelayActor system manages relay connections with exponential back-off, automatic reconnection, and status broadcasting to ensure fallback paths are always available.
  • PathList and PathEvent APIs provide real-time visibility into transport state changes without requiring manual connection management.
  • Failed direct paths trigger automatic relay fallback until NAT traversal succeeds again, at which point the connection seamlessly migrates back to the direct path.

Frequently Asked Questions

How does iroh decide when to switch from direct to relay connections?

Iroh attempts addresses in strict order—direct IPs first, then relay URLs. If the QUIC handshake fails on a direct address or the established direct path reports a ConnectionError, the Connecting future automatically tries the next address in the list. Once connected, the PathList monitors path health; if the selected direct path times out or drops, traffic automatically flows through the relay path that has been kept alive in parallel by the ActiveRelayActor.

What happens if the relay server itself becomes unavailable?

Each ActiveRelayActor implements exponential back-off retry logic within its run_once loop. If a relay connection drops, the actor continuously attempts to reconnect while updating the shared RelayStatus to Disconnected. If all configured relays fail, the connection relies solely on direct paths, or fails entirely if no direct path exists and RelayMode is not disabled. Multiple relay URLs can be configured to improve redundancy.

Can I disable relay fallback for security or compliance reasons?

Yes. Configure the endpoint with RelayMode::Disabled when building the connection. This prevents the inclusion of relay addresses in the EndpointAddr and stops the RelayActor from initializing, ensuring all traffic flows exclusively through direct peer-to-peer connections. Note that this may reduce connectivity success rates in restrictive network environments.

How can I monitor which transport path is currently active?

Use the Connection::path_events() method to obtain a stream of PathEvent notifications that report when paths are selected, changed, or closed. Additionally, Connection::paths() returns the current PathList containing both active and standby paths. This allows applications to observe transport layer changes—such as failover to relay or recovery to direct paths—in real-time without polling.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →