How to Integrate iroh with Different Rust Asynchronous Runtimes: Tokio and async-std
iroh requires a Tokio runtime to function because it relies on Tokio-specific primitives like tokio::spawn, tokio::time, and tokio::sync channels throughout its networking stack, so you must either run your entire application on Tokio or embed a Tokio runtime within other async executors like async-std.
iroh is an async-first peer-to-peer networking library that provides QUIC-based connectivity for distributed applications. While powerful for building decentralized systems, it is tightly coupled to Tokio's runtime according to the source code in the n0-computer/iroh repository. This guide explains exactly how to integrate iroh with different Rust asynchronous runtimes based on the actual implementation in iroh/src/endpoint.rs and related modules.
Why iroh Requires Tokio
The iroh crate declares Tokio as a mandatory dependency in iroh/Cargo.toml, and the core API fundamentally assumes a Tokio executor is present. When you call Endpoint::builder(...).bind().await in iroh/src/endpoint.rs, the implementation implicitly creates a tokio::runtime::Handle and spawns background tasks for the QUIC stack, address-lookup services, and relay handling.
Internal modules further enforce this dependency. For example, iroh/src/socket/transports/relay/actor.rs directly imports tokio::sync channels and tokio_util::sync::CancellationToken, which only function correctly when a Tokio executor is running. These Tokio-specific types are woven throughout the networking layer, making it impossible to run iroh on a generic async executor without Tokio present.
Standard Integration with Tokio
Since iroh is built on Tokio, the simplest and most performant approach is to use Tokio for your entire application. The official examples demonstrate this pattern using the #[tokio::main] attribute.
In iroh/examples/echo.rs, the canonical implementation looks like this:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let endpoint = iroh::Endpoint::builder(iroh::endpoint::presets::N0)
.bind()
.await?;
// Accept connections and handle QUIC streams...
Ok(())
}
This approach works because #[tokio::main] initializes a multi-threaded Tokio runtime that drives all iroh tasks, including the internal background workers spawned during bind().
Using iroh with async-std
If you maintain an existing async-std codebase, you cannot eliminate Tokio, but you can isolate it. You have two idiomatic ways to provide the required Tokio executor while keeping your outer application on async-std.
Option 1: Explicit Tokio Runtime
Build a dedicated Tokio runtime locally and block on it within your async-std task. This isolates the Tokio executor from async-std while satisfying iroh's requirements.
use async_std::task;
use iroh::{Endpoint, endpoint::presets};
fn main() {
task::block_on(async {
// Build a dedicated Tokio runtime for iroh
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.expect("create Tokio runtime");
// Run iroh inside that runtime
rt.block_on(async {
let endpoint = Endpoint::builder(presets::N0)
.bind()
.await
.expect("bind endpoint");
// Use the endpoint (dial, accept, etc.) here
// ...
});
});
}
The rt.block_on call creates a Tokio executor that drives all iroh tasks, while the outer async_std::task::block_on simply waits for the operation to complete.
Option 2: Using the async_compat Crate
The async_compat crate provides a thin wrapper that lets Tokio futures run on any executor. This approach requires less boilerplate but adds a small overhead.
use async_std::task;
use async_compat::CompatExt; // adds `.compat()` to futures
use iroh::{Endpoint, endpoint::presets};
fn main() {
task::block_on(async {
// The future returned by `bind` is a Tokio future
let endpoint = Endpoint::builder(presets::N0)
.bind()
.await
.compat()
.await
.expect("bind endpoint");
// Now you can use `endpoint` inside async-std as usual
// ...
});
}
The .compat() method converts the Tokio-based future into a generic Future that async-std can poll. Internally, it spins up a hidden Tokio runtime, so you do not have to manage one manually.
Key Considerations and Performance Implications
When deciding how to integrate iroh with your existing runtime, consider the following trade-offs:
-
New projects: Use Tokio (
#[tokio::main]) directly. Since iroh already depends on Tokio, requiring it as your primary runtime eliminates glue code and potential compatibility issues. -
Existing async-std codebases: Use the explicit Tokio runtime method for production systems where you need fine-grained control, or use
async_compatfor rapid prototyping. -
Mixed executors: If you must intermix Tokio and async-std tasks, create a multi-threaded Tokio runtime and spawn async-std futures onto it via
tokio::task::spawn_blocking, or vice versa. This keeps both executors alive while letting each library use its native primitives.
Critical configuration: When embedding a Tokio runtime, always call enable_all() on the tokio::runtime::Builder. Without this flag, iroh's internal timers (tokio::time) and I/O utilities will panic at runtime.
Performance note: The async_compat crate adds a small overhead due to the extra task wrapper. For low-level networking scenarios requiring maximum throughput, the explicit runtime method defined in iroh/src/endpoint.rs is more robust.
Summary
- iroh is architecturally dependent on Tokio primitives found in
iroh/src/endpoint.rsandiroh/src/socket/transports/relay/actor.rs, requiring a Tokio runtime for all operations. - For Tokio-based applications, use
#[tokio::main]and follow the patterns iniroh/examples/echo.rsfor the cleanest integration. - For async-std applications, wrap iroh calls in a dedicated Tokio runtime using
tokio::runtime::Builderor employ theasync_compatcrate to bridge the executors. - Always configure the Tokio runtime with
enable_all()to ensure timers and I/O drivers are available for iroh's background tasks.
Frequently Asked Questions
Can I use iroh without Tokio?
No, iroh does not expose a no-tokio feature in iroh/Cargo.toml. The crate depends on Tokio-specific types like tokio::sync channels and tokio_util::sync::CancellationToken throughout its internals, including the relay actor implementation in iroh/src/socket/transports/relay/actor.rs. Completely dropping Tokio is not possible with the current architecture.
What is the performance cost of using async_compat with iroh?
The async_compat crate adds a small overhead due to the extra task wrapper that bridges the two executors. While convenient for prototyping, the explicit Tokio runtime method is more robust for production systems handling high-throughput QUIC streams.
How do I ensure iroh timers work correctly when embedding Tokio in async-std?
When building the Tokio runtime with tokio::runtime::Builder, you must call enable_all() to ensure the timer and I/O drivers are initialized. Without this configuration, calls to tokio::time utilities inside iroh will panic when the code attempts to create timeouts or delays.
Can I mix Tokio and async-std tasks in the same application with iroh?
Yes, you can create a multi-threaded Tokio runtime and spawn async-std futures onto it using tokio::task::spawn_blocking, or run async-std as the outer executor with an embedded Tokio runtime specifically for iroh operations. This approach keeps both executors alive while satisfying iroh's strict runtime requirements.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →