# How to Build a Basic Echo Server and Client with Iroh

> Build a basic echo server and client with Iroh. Learn to create an Endpoint, implement ProtocolHandler, and spawn a Router for robust QUIC connections with NAT traversal and relay fallback.

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

---

**You can build an echo server and client with Iroh by creating an `Endpoint` using the builder pattern, implementing the `ProtocolHandler` trait to handle bidirectional streams, and spawning a `Router` that manages QUIC connections while automatically traversing NAT devices and falling back to relays.**

The **iroh** crate from the n0-computer/iroh repository provides a high-level networking abstraction that eliminates the complexity of manual QUIC handshakes and hole-punching. By following the patterns established in [`iroh/examples/echo.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/echo.rs), you can create a production-ready echo service that functions across heterogeneous networks without dedicated infrastructure or port forwarding rules.

## Understanding the Iroh Networking Stack

Iroh simplifies peer-to-peer communication through three core abstractions defined in the source tree.

**Endpoint** – The central networking node defined in [`iroh/src/endpoint.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/endpoint.rs) (lines 47-49 for the builder) that owns UDP sockets, TLS credentials, and relay connections. An endpoint can both initiate outbound connections via `Endpoint::connect` and accept inbound connections.

**ProtocolHandler** – A user-defined trait implementation that determines how to process incoming connections. The handler's `accept` method receives a raw QUIC `Connection` object and manages the stream lifecycle.

**Router** – A convenience wrapper that binds protocol handlers to specific ALPN identifiers and runs the acceptance loop on a dedicated Tokio task, implemented in the protocol module.

## Step 1: Bind the Server Endpoint

Start by creating an endpoint using the preset configuration that enables automatic relay fallback. In [`iroh/src/endpoint.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/endpoint.rs), the `Endpoint::bind` method initializes the networking stack:

```rust
use iroh::{Endpoint, presets};
use n0_error::Result;

const ALPN: &[u8] = b"iroh-example/echo/0";

#[tokio::main]
async fn main() -> Result<()> {
    // Create the endpoint with default relay configuration
    let endpoint = Endpoint::bind(presets::N0).await?;
    println!("Server node ID: {:?}", endpoint.node_id());
    Ok(())
}

```

The `presets::N0` constant configures the endpoint to use the default relay servers hosted by n0, ensuring connectivity even when both peers reside behind NAT devices without manual configuration.

## Step 2: Implement the ProtocolHandler Trait

Create a struct that implements `ProtocolHandler` to handle incoming echo connections. According to the implementation in [`iroh/examples/echo.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/echo.rs) (lines 80-102), the handler must provide an `accept` method that processes bidirectional streams using `tokio::io::copy`:

```rust
use iroh::protocol::{ProtocolHandler, AcceptError};
use iroh::endpoint::Connection;
use n0_error::Result;
use tokio::io;

#[derive(Debug, Clone)]
struct Echo;

impl ProtocolHandler for Echo {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        // Accept a bidirectional stream from the client
        let (mut send, mut recv) = connection.accept_bi().await?;
        
        // Copy all received data back to the sender
        io::copy(&mut recv, &mut send).await?;
        
        // Signal EOF and wait for remote close
        send.finish()?;
        connection.closed().await;
        Ok(())
    }
}

```

The `accept_bi` method returns a `(SendStream, RecvStream)` tuple. The `tokio::io::copy` utility efficiently pipes data from the receive stream back to the send stream without intermediate buffering, while `send.finish()` signals the end of the stream to the client.

## Step 3: Spawn the Router

The `Router` manages the relationship between ALPN identifiers and protocol handlers. As shown in [`iroh/examples/echo.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/echo.rs) (lines 71-77), chain the builder methods to register your handler and spawn the accept loop:

```rust
use iroh::protocol::Router;

let router = Router::builder(endpoint)
    .accept(ALPN, Echo)
    .spawn();

// Wait until the endpoint is reachable via relay
router.endpoint().online().await;
println!("Server online at: {:?}", router.endpoint().addr());

```

The router runs continuously in the background, routing incoming connections matching the `ALPN` byte string to your `Echo` handler. The `online().await` call blocks until the endpoint has established contact with at least one relay server.

## Step 4: Build the Client Connection

The client follows a similar endpoint creation pattern but uses `Endpoint::connect` (defined in [`iroh/src/endpoint.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/endpoint.rs), lines 48-67) to establish the outbound connection:

```rust
async fn run_client(server_addr: iroh::EndpointAddr) -> Result<()> {
    let client = Endpoint::bind(presets::N0).await?;
    
    // Connect to server using the same ALPN
    let conn = client.connect(server_addr, ALPN).await?;
    
    // Open a bidirectional stream
    let (mut send, mut recv) = conn.open_bi().await?;
    
    // Send payload and signal EOF
    send.write_all(b"Hello, Iroh!").await?;
    send.finish()?;
    
    // Read echoed response (limit 1KB)
    let response = recv.read_to_end(1000).await?;
    assert_eq!(&response, b"Hello, Iroh!");
    
    // Graceful shutdown
    conn.close(0u32.into(), b"bye!");
    client.close().await;
    Ok(())
}

```

The client must use the identical ALPN identifier (`b"iroh-example/echo/0"`) to negotiate the protocol with the server during the TLS handshake. The `open_bi` method creates a fresh bidirectional stream, distinct from the streams accepted by the server side.

## Complete Working Example

Combine these components into a single executable that demonstrates both server and client functionality:

```rust
use iroh::{
    Endpoint, EndpointAddr,
    endpoint::{Connection, presets},
    protocol::{AcceptError, ProtocolHandler, Router},
};
use n0_error::{Result, StdResultExt};
use tokio::io;

const ALPN: &[u8] = b"iroh-example/echo/0";

#[derive(Debug, Clone)]
struct Echo;

impl ProtocolHandler for Echo {
    async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
        let (mut send, mut recv) = connection.accept_bi().await?;
        io::copy(&mut recv, &mut send).await?;
        send.finish()?;
        connection.closed().await;
        Ok(())
    }
}

#[tokio::main]
async fn main() -> Result<()> {
    // Initialize server
    let server_endpoint = Endpoint::bind(presets::N0).await?;
    let router = Router::builder(server_endpoint)
        .accept(ALPN, Echo)
        .spawn();
    
    router.endpoint().online().await;
    let server_addr = router.endpoint().addr();
    println!("Server ready at: {:?}", server_addr);
    
    // Run client
    let client = Endpoint::bind(presets::N0).await?;
    let conn = client.connect(server_addr, ALPN).await.anyerr()?;
    
    let (mut send, mut recv) = conn.open_bi().await.anyerr()?;
    send.write_all(b"Test message").await.anyerr()?;
    send.finish().anyerr()?;
    
    let echoed = recv.read_to_end(1024).await.anyerr()?;
    println!("Received: {:?}", String::from_utf8_lossy(&echoed));
    
    // Cleanup
    conn.close(0u32.into(), b"done");
    router.shutdown().await.anyerr()?;
    client.close().await;
    
    Ok(())
}

```

This example compiles against the standard Iroh crate and leverages the `n0_error` utility for ergonomic error handling, though external projects may substitute `anyhow` or `std::io::Result`.

## Summary

- **Endpoint creation** – Use `Endpoint::bind(presets::N0)` in [`iroh/src/endpoint.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/endpoint.rs) to create a node with automatic relay support and NAT traversal.
- **Protocol implementation** – Implement `ProtocolHandler` for your application logic, handling `Connection` objects via `accept_bi` to obtain bidirectional streams.
- **Router registration** – Register handlers with `Router::builder(endpoint).accept(ALPN, handler).spawn()` to start the accept loop.
- **Client connections** – Clients use `endpoint.connect(addr, ALPN)` from [`iroh/src/endpoint.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/endpoint.rs) (lines 48-67) and `open_bi()` to establish streams and exchange data.
- **Graceful shutdown** – Close individual streams with `send.finish()`, close connections with `conn.close()`, and terminate endpoints with `endpoint.close().await`.

## Frequently Asked Questions

### What role does the ALPN identifier play in Iroh connections?

The **ALPN** (Application-Layer Protocol Negotiation) identifier is a byte string that both the client and server exchange during the TLS handshake to agree on which protocol handler to use. In [`iroh/src/endpoint.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/endpoint.rs), the server routes incoming connections to the appropriate `ProtocolHandler` based on this identifier. Mismatched ALPN values result in immediate connection termination during the handshake phase, as the router cannot dispatch to a handler.

### How does Iroh handle NAT traversal without explicit configuration?

Iroh's **Endpoint** type automatically coordinates hole-punching and relay fallback through the internal `noq` transport layer defined in [`iroh/src/socket/quic.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/socket/quic.rs). When you use `presets::N0`, the endpoint attempts direct UDP connectivity first, then utilizes STUN/ICE protocols for NAT traversal, and finally falls back to publicly hosted relay servers if direct paths fail. This behavior is encapsulated in the `Endpoint::bind` implementation and requires no additional code from the application developer.

### Can the echo server handle multiple concurrent connections?

Yes. The `Router` spawns the `ProtocolHandler::accept` method in a new Tokio task for every incoming connection. As implemented in [`iroh/examples/echo.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/examples/echo.rs), each connection gets its own bidirectional stream pair, allowing the server to process multiple clients simultaneously without blocking the accept loop. The handler simply processes the connection to completion before returning, at which point the task cleans up the resources.

### Where is the ProtocolHandler trait defined in the source code?

The `ProtocolHandler` trait is defined in the protocol module, accessible through [`iroh/src/protocol/mod.rs`](https://github.com/n0-computer/iroh/blob/main/iroh/src/protocol/mod.rs). The trait requires a single asynchronous method `accept(&self, connection: Connection)` that returns `Result<(), AcceptError>`. This design allows you to implement stateless handlers like the echo example, or stateful handlers that use struct fields to track connection metrics or session data across multiple streams within the same connection.