Using iroh in WebAssembly and Browser Environments: A Complete Guide

iroh compiles to WebAssembly using conditional compilation to provide browser-compatible async runtimes and relay-only networking, enabling direct peer-to-peer connections in browsers without native UDP sockets.

The iroh library from n0-computer/iroh is architected to run across native platforms and WebAssembly (Wasm) environments using a single Rust codebase. By leveraging conditional compilation attributes, iroh adapts its runtime and transport layers to browser constraints while maintaining API compatibility. This guide explains how to build, configure, and deploy iroh in WebAssembly and browser environments based on the actual implementation in the source code.

Conditional Compilation Architecture

iroh uses #[cfg(wasm_browser)] and #[cfg(not(wasm_browser))] attributes to isolate platform-specific code paths. This approach allows the crate to declare a single crate-type = ["lib", "cdylib"] in Cargo.toml while supporting both native targets and wasm32-unknown-unknown.

The conditional compilation affects three critical subsystems:

  • Runtime: Native builds use Tokio-based task trackers, while Wasm builds delegate to wasm_bindgen_futures::spawn_local
  • Timers: Browser implementations use n0_future::time::Sleep wrapped in a web::Timer struct instead of Tokio timers
  • Transports: The TransportConfig enum removes the Ip variant in Wasm builds, restricting connections to relay and custom transports only

Runtime Implementation in iroh/src/runtime.rs

The core adaptation lives in iroh/src/runtime.rs, where the Runtime struct implements the noq::Runtime trait. When compiled for wasm_browser, this file provides a lightweight runtime that differs significantly from its native counterpart:

Native Runtime (#[cfg(not(wasm_browser))]):

  • Implements noq::Runtime with new_timer delegating to noq::TokioRuntime
  • Uses TaskTracker and cancellation tokens for async task management
  • Provides full shutdown and abort capabilities

Browser Runtime (#[cfg(wasm_browser)]):

  • Spawns tasks using wasm_bindgen_futures::spawn_local, scheduling futures on the JavaScript micro-task queue
  • Implements web::Timer wrapping n0_future::time::Sleep for QUIC timer compatibility
  • Provides no-op shutdown and abort methods since browsers lack process termination semantics

This design ensures that async operations use the browser's event loop without requiring a separate thread pool, which is impossible in Wasm's single-threaded environment.

Transport Layer Limitations in iroh/src/socket/transports.rs

Browser security models prohibit raw UDP socket access, forcing iroh to modify its transport layer in iroh/src/socket/transports.rs. When targeting WebAssembly, the codebase eliminates IP transport capabilities while preserving relay functionality:

  • The TransportConfig::Ip variant is guarded by #[cfg(not(wasm_browser))]
  • The LocalAddrsWatch type monitors only Relay and Custom transports
  • Direct peer-to-peer connections rely on iroh's relay network for NAT traversal

This restriction means Wasm builds operate in relay-only mode, which is sufficient for most browser use cases. The relay network handles NAT traversal and provides fallback connectivity when direct UDP is unavailable.

Dependencies and Cargo Configuration

The Cargo.toml in the iroh crate declares WebAssembly-specific dependencies under conditional targets:

[dependencies]

# Native dependencies

tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"

[target.'cfg(all(target_family = "wasm", target_os = "unknown"))'.dependencies]
wasm-bindgen-futures = "0.4"
time = { version = "0.3", features = ["wasm-bindgen"] }
getrandom = { version = "0.2", features = ["wasm_js"] }

The wasm-bindgen feature on the time crate ensures timer compatibility, while getrandom with the wasm_js feature provides cryptographically secure random number generation required for QUIC handshakes.

Building for WebAssembly

To compile iroh for browser environments, follow the standard wasm-bindgen workflow:

  1. Build the Wasm artifact:
cargo build --release --target wasm32-unknown-unknown

This generates target/wasm32-unknown-unknown/release/iroh.wasm.

  1. Generate JavaScript bindings:
wasm-bindgen target/wasm32-unknown-unknown/release/iroh.wasm \
    --out-dir ./pkg --target web

The --target web flag produces ES6 modules suitable for modern bundlers or direct browser import.

Code Examples

Initializing an Endpoint in the Browser

The following Rust code demonstrates creating a relay-only endpoint suitable for WebAssembly compilation. Note the absence of IP transport configuration and the use of wasm_bindgen for JavaScript interoperability:

use iroh::{Endpoint, address_lookup::pkarr::PkarrResolver};
use iroh::endpoint::presets;
use wasm_bindgen::prelude::*;

const ECHO_ALPN: &[u8] = b"echo";

#[wasm_bindgen]
pub async fn start_endpoint() -> Result<JsValue, JsValue> {
    // Relay-only endpoint (IP transport disabled in Wasm)
    let endpoint = Endpoint::builder(presets::N0)
        .relay_mode(iroh::RelayMode::Staging)
        .bind()
        .await
        .map_err(|e| e.to_string())?;

    // Resolve endpoint ID via PKARR DNS
    let resolver = PkarrResolver::n0_dns().build(endpoint.tls_config().clone());
    let stream = resolver
        .resolve(endpoint.id())
        .await
        .ok_or_else(|| "no DNS records".to_string())?;

    Ok(JsValue::from_str(&endpoint.id().to_string()))
}

Key implementation details:

  • The Runtime automatically uses web::Timer and wasm_bindgen_futures::spawn_local internally
  • The bind() method succeeds because the transport layer excludes IP variants in Wasm builds
  • Error handling converts Rust errors to JavaScript-compatible JsValue types

Connecting from JavaScript

After generating bindings with wasm-bindgen, consume the library in a browser script:

import init, { start_endpoint, connect_to } from './iroh.js';

async function runDemo() {
  await init();
  const myId = await start_endpoint();
  console.log('My EndpointId:', myId);

  // Connect to remote peer
  const remoteId = "a1b2c3d4...";
  const conn = await connect_to(remoteId, "echo");
  const { send, recv } = await conn.openBi();

  await send.writeAll(new TextEncoder().encode("Hello from Wasm!"));
  await send.finish();

  const response = await recv.readToEnd(1024);
  console.log(new TextDecoder().decode(response));
}

runDemo();

The connect_to function (defined in Rust) calls endpoint.connect(remote_id, alpn).await, with all async operations scheduled via the browser's event loop through wasm_bindgen_futures::spawn_local.

Testing and Integration

The repository includes Wasm-specific integration tests in iroh/tests/integration.rs that use wasm-bindgen-test for browser validation. These tests exercise the same high-level API (Endpoint::builder(...).bind().await) but switch to wasm_tracing for logging when compiled for the browser target.

To run browser tests:

wasm-pack test --headless --firefox

Summary

  • iroh uses #[cfg(wasm_browser)] conditional compilation to support WebAssembly without breaking native functionality
  • The runtime in iroh/src/runtime.rs adapts task spawning to wasm_bindgen_futures::spawn_local and timers to n0_future::time::Sleep in browser environments
  • Transports are restricted to relay and custom variants in iroh/src/socket/transports.rs because browsers cannot open raw UDP sockets
  • Building requires targeting wasm32-unknown-unknown and using wasm-bindgen to generate JavaScript bindings
  • The high-level API remains identical between native and Wasm, with Endpoint::bind() automatically selecting the appropriate runtime and transport configuration

Frequently Asked Questions

Why can't I use direct IP connections in the browser?

Browsers sandbox network access and prohibit raw UDP socket creation for security reasons. As implemented in iroh/src/socket/transports.rs, the TransportConfig::Ip variant is compiled out in Wasm builds, leaving only relay and custom transports. This forces connections through iroh's relay network, which handles NAT traversal and message forwarding on behalf of browser clients.

How do timers work differently in Wasm versus native platforms?

Native iroh builds use Tokio's timer wheel implementation, but Wasm lacks access to system timer APIs. In iroh/src/runtime.rs, the browser runtime defines web::Timer which wraps n0_future::time::Sleep to provide async/.await compatible delays. This ensures QUIC protocol timers and connection timeouts function correctly within the browser's single-threaded event loop.

Can I use custom transports in browser environments?

Yes. While the Ip transport is unavailable in Wasm, the TransportConfig enum retains the Custom variant in iroh/src/socket/transports.rs. You can implement custom transport logic using WebRTC data channels, WebTransport, or other browser-supported networking primitives, provided you implement the required traits and handle the JavaScript interoperability yourself.

What is the performance impact of relay-only mode?

Relay-only connections introduce additional latency compared to direct UDP connections because traffic routes through relay servers. However, iroh's relay protocol is optimized for low latency, and the browser constraint makes this trade-off necessary. The LocalAddrsWatch type in transport configuration ensures the endpoint automatically manages relay connections without API changes, maintaining the same async interface as native builds.

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 →