How iroh Connection Establishment Works: Direct QUIC and Relay Server Fallback

iroh attempts direct QUIC connections first, then automatically falls back to relay servers when NAT traversal fails, using a unified Connection abstraction that hides transport details from application code.

iroh's connection establishment strategy prioritizes low-latency direct peer-to-peer links while maintaining reliable connectivity through relay servers when network conditions require it. In the n0-computer/iroh repository, this hybrid approach is implemented across the endpoint and transport layers, allowing developers to use a single Endpoint::connect API regardless of whether the underlying path is direct or relayed.

Direct QUIC Connection Establishment

Endpoint Configuration and Binding

The connection process begins in src/endpoint.rs, where Endpoint::new constructs the QUIC transport layer. This initializes the UDP socket and configures the TLS 1.3 credentials required for QUIC. The transport implementation resides in src/endpoint/quic.rs, which wraps the quinn library to handle the actual wire protocol.

When the endpoint is ready, Endpoint::connect (lines 1190–1210 in src/endpoint.rs) spawns a Connecting future that initiates the dial process through quic::dial.

The QUIC Handshake Implementation

The direct path uses standard QUIC with TLS 1.3. The handshake completes when the local endpoint receives a pong response from the remote peer, at which point the Connection object is returned to the caller. This logic is implemented between lines 150–190 in src/endpoint/quic.rs.

If the handshake succeeds, all subsequent datagrams travel over the encrypted QUIC stream without additional overhead. If it fails with ConnectionError::Timeout or ConnectionError::NoRoute, iroh automatically triggers the relay fallback mechanism.

Zero-RTT Session Resumption

iroh supports Zero-RTT (Zero Round-Trip Time) data transmission when reconnecting to a previously known peer. The ZeroRttStatus enum tracks whether early data was accepted during the handshake, allowing the endpoint to begin sending application data before the TLS 1.3 handshake formally completes. This optimization is handled in the connection setup logic around lines 1150–1190 in src/endpoint.rs.

Relay Server Fallback Path

When direct QUIC dialing fails or the remote peer is behind a restrictive NAT, iroh uses the relay infrastructure. The relay system is managed by the RelayActor subsystem in src/socket/transports/relay/actor.rs.

Relay Actor State Management

The RelayActor maintains a map of active relay connections, spawning one ActiveRelayActor per relay URL. When a datagram needs to be sent to a remote whose home relay is known, RelayActor::active_relay_handle_for_endpoint (lines 59–86) either reuses an existing actor or spawns a new one to handle the connection lifecycle.

Relay Dialing and Handshake Protocol

Each ActiveRelayActor runs a state machine that begins with the run_dialing state. It repeatedly calls ClientBuilder::connect() with a CONNECT_TIMEOUT of 10 seconds. Once the TCP or QUIC connection to the relay is established, the client sends a ClientToRelayMsg::Handshake message.

The handshake completes when the client receives a Pong from the relay server, handled in handle_relay_msg (lines 92–100). Until this pong arrives, the connection is considered tentative; receipt transitions the actor to ConnectedRelayState::established.

Keep-Alive Mechanisms

To maintain NAT mappings and detect dead connections, the relay actor sends periodic Ping frames every 15 seconds (PING_INTERVAL). If a corresponding Pong never arrives, the connection is marked as a handshake failure and the actor enters an exponential backoff before retrying. This logic is implemented around lines 65–71 in src/socket/transports/relay/actor.rs.

Datagram Forwarding Logic

Once the relay handshake is complete, ActiveRelayActor::run_connected forwards incoming RelayToClientMsg::Datagrams to the AsyncUdpSocket via an mpsc channel (relay_datagrams_recv). Outgoing datagrams are queued on relay_datagrams_send and transmitted in batches of SEND_DATAGRAM_BATCH_SIZE (20) to optimize throughput. This forwarding loop is found at lines 124–132.

Home Relay Lifecycle Management

Each endpoint designates one relay as its home relay, which remains persistently connected even when idle. The HomeRelayWatch watchable (lines 440–452) monitors this status, while non-home relays are shut down after RELAY_INACTIVE_CLEANUP_TIME (60 seconds) of inactivity to conserve resources.

Unified Connection API

Both direct and relay paths expose the same Connection type, ensuring that higher-level APIs (such as file transfer or search) remain agnostic to the transport method. The Endpoint::connect method tries the direct path first; only upon failure does it query the RelayMap (configured in iroh-base/src/relay_url.rs) to discover the remote's home relay and initialize the fallback path.

You can inspect the transport type at runtime using Connection::is_direct(), which checks whether the underlying transport is a quic::Connection or a relay::Connection as implemented in src/endpoint/connection.rs.

Code Examples

Establishing a Direct Connection

use iroh::Endpoint;
use iroh::endpoint::EndpointId;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Create a local endpoint listening on a random UDP port
    let endpoint = Endpoint::builder().bind().await?;
    
    // Remote peer's endpoint id (obtained out-of-band)
    let remote_id: EndpointId = "...".parse()?;
    
    // Attempt direct QUIC connection; returns a Connecting future
    let conn = endpoint.connect(remote_id).await?;
    println!("Direct QUIC connection established: {}", conn.remote_id());
    Ok(())
}

Source: The connect implementation is in src/endpoint.rs (lines 1190–1210).

Automatic Relay Fallback

use iroh::Endpoint;
use iroh::endpoint::EndpointId;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Configure endpoint with public relay map
    let endpoint = Endpoint::builder()
        .relay_map(iroh_relay::client::RelayMap::default())
        .bind()
        .await?;
    
    let remote_id: EndpointId = "...".parse()?;
    
    // connect() tries direct first, then falls back to RelayActor automatically
    let conn = endpoint.connect(remote_id).await?;
    println!("Connection ready: {}", conn.remote_id());
    Ok(())
}

Source: Fallback logic is triggered in Endpoint::connect and RelayActor::try_send_datagram (around line 990 in src/socket/transports/relay/actor.rs).

Inspecting Connection Type

use iroh::Endpoint;
use iroh::endpoint::EndpointId;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let endpoint = Endpoint::builder().bind().await?;
    let remote = "...".parse::<EndpointId>()?;
    
    let conn = endpoint.connect(remote).await?;
    if conn.is_direct() {
        println!("Using direct QUIC connection");
    } else {
        println!("Using relay server connection");
    }
    Ok(())
}

Source: Connection::is_direct() is defined in src/endpoint/connection.rs.

Summary

  • iroh connection establishment prioritizes direct QUIC connections using UDP and TLS 1.3 handshakes implemented in src/endpoint/quic.rs.
  • When direct connections fail, the system automatically falls back to relay servers managed by RelayActor and ActiveRelayActor in src/socket/transports/relay/actor.rs.
  • Both paths use the same Connection abstraction, with Connection::is_direct() available to inspect the transport type when needed.
  • Relay connections use a 10-second connect timeout, 15-second ping intervals, and batch datagram transmission in groups of 20 to optimize performance.
  • The home relay mechanism ensures persistent connectivity while cleaning up inactive relay connections after 60 seconds of idleness.

Frequently Asked Questions

How does iroh decide when to use a relay server instead of a direct connection?

iroh always attempts a direct QUIC connection first through Endpoint::connect in src/endpoint.rs. If the handshake times out or returns ConnectionError::NoRoute, the endpoint queries the RelayMap for the remote peer's home relay and initiates a relay connection via RelayActor::active_relay_handle_for_endpoint. This fallback happens automatically without requiring changes to application code.

What is the timeout for establishing a relay connection?

The ActiveRelayActor uses a CONNECT_TIMEOUT of 10 seconds when dialing a relay server via ClientBuilder::connect(). If the TCP or QUIC connection to the relay cannot be established within this window, the actor enters a backoff state before retrying, as implemented in src/socket/transports/relay/actor.rs.

Can iroh send data before the QUIC handshake completes?

Yes. iroh supports Zero-RTT (Zero Round-Trip Time) data transmission when resuming a previously established session. The ZeroRttStatus enum tracks whether early data was accepted, allowing the endpoint to begin transmitting before the TLS 1.3 handshake finishes, reducing latency for subsequent connections to known peers.

How does iroh keep relay connections alive?

Each ActiveRelayActor sends periodic Ping frames every 15 seconds (PING_INTERVAL). The connection is considered established only after receiving a corresponding Pong from the relay server. If no Pong arrives, the handshake is marked as failed and the actor backs off before attempting to reconnect, as defined in src/socket/transports/relay/actor.rs.

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 →