Iroh QUIC Stream Implementation: Bidirectional vs Unidirectional Streams Explained
Iroh distinguishes between bidirectional and unidirectional QUIC streams through the open_bi(), open_uni(), accept_bi(), and accept_uni() methods on Connection, where bidirectional streams combine a send and receive channel while unidirectional streams offer only one-way communication.
The n0-computer/iroh repository implements a peer-to-peer networking stack built on QUIC, exposing stream abstractions that allow developers to choose between full-duplex and half-duplex communication patterns. Understanding the differences between these Iroh QUIC stream types is essential for optimizing data transfer in distributed applications.
Understanding QUIC Stream Types in Iroh
Iroh provides four fundamental operations for stream creation, corresponding to the two directionalities defined in the QUIC specification.
Bidirectional Streams
Bidirectional streams allow both endpoints to send and receive data on the same logical channel. When you call open_bi() on a Connection, the method returns a tuple of (SendStream, RecvStream), representing the outgoing and incoming halves of the channel.
According to the implementation in iroh/src/endpoint/connection.rs (lines 862-886), open_bi() delegates to the underlying noq crate:
// From iroh/src/endpoint/connection.rs#L862-L886
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), ConnectionError> {
let (send, recv) = self.inner.open_bi().await?;
Ok((SendStream::new(send), RecvStream::new(recv)))
}
The corresponding accept_bi() method waits for incoming bidirectional streams initiated by the peer. Bidirectional streams are essentially two linked unidirectional streams—one flowing in each direction—exposed as a single logical abstraction.
Unidirectional Streams
Unidirectional streams support data flow in only one direction. The open_uni() method creates an outgoing stream where the local side can only send data, while accept_uni() accepts an incoming stream where the local side can only receive.
In iroh/src/endpoint/connection.rs (lines 74-76), the implementation pattern mirrors the bidirectional approach:
// Returns SendStream only
let send = conn.open_uni().await?;
The peer must explicitly call accept_uni() to receive this stream, creating a clear separation between sender and receiver responsibilities.
Implementation Details in the Iroh Source Code
The stream implementation resides primarily in iroh/src/endpoint/connection.rs, with type definitions and re-exports located in iroh/src/endpoint/quic.rs.
Iroh's QUIC layer is deliberately thin; the Connection type wraps an inner noq::Connection and delegates heavy lifting—such as frame handling, flow control, and congestion control—to the underlying noq crate. As implemented in n0-computer/iroh, the open_bi() method creates a noq::OpenBi future, which resolves to a pair of related unidirectional streams that satisfy the QUIC specification's requirement that bidirectional streams act as coordinated pairs.
Both stream types are cheap to create. A single QUIC connection can support up to 2⁶² total streams (the sum of uni- and bidirectional), effectively providing unlimited capacity for typical workloads (see connection.rs lines 63-66).
Practical Code Examples
Opening a Bidirectional Stream
Use open_bi() when you need to send data and receive a response on the same logical channel:
use iroh::endpoint::Connection;
async fn chat_bidirectional(conn: &Connection) -> Result<(), Box<dyn std::error::Error>> {
// Initiate bidirectional stream
let (mut send, mut recv) = conn.open_bi().await?;
// Send request
send.write_all(b"Hello from client").await?;
send.finish().await?;
// Read response
let mut buf = vec![0; 1024];
let n = recv.read(&mut buf).await?;
println!("Server replied: {}", String::from_utf8_lossy(&buf[..n]));
Ok(())
}
Sending via Unidirectional Stream
Use open_uni() for fire-and-forget patterns where no immediate response is needed:
async fn send_large_blob(conn: &Connection, data: &[u8]) -> Result<(), Box<dyn std::error::Error>> {
// Open unidirectional stream (send-only)
let mut send = conn.open_uni().await?;
// Stream data without waiting for reply
send.write_all(data).await?;
send.finish().await?;
Ok(())
}
Accepting a Unidirectional Stream
The receiving side uses accept_uni() to handle incoming one-way data:
async fn receive_data(conn: &Connection) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Wait for incoming unidirectional stream
let mut recv = conn.accept_uni().await?;
// Read all data into buffer
let mut data = Vec::new();
recv.read_to_end(&mut data).await?;
println!("Received {} bytes", data.len());
Ok(data)
}
When to Use Each Stream Type
Choose your stream type based on your communication pattern:
-
Bidirectional streams (
open_bi/accept_bi): Use for RPC-style interactions, request-response protocols, or any scenario requiring correlated two-way communication on a single channel. TheSendStreamandRecvStreampair maintains ordering and flow control across both directions. -
Unidirectional streams (
open_uni/accept_uni): Use for event streaming, file transfers, or telemetry where the sender pushes data without expecting a direct reply. This pattern reduces overhead by avoiding the allocation of a return channel when it is not needed.
Summary
- Bidirectional streams in Iroh provide full-duplex communication through
open_bi()andaccept_bi(), returning a(SendStream, RecvStream)tuple that maps to a pair of related unidirectional primitives in the underlyingnoqcrate. - Unidirectional streams offer half-duplex communication via
open_uni()(send-only) andaccept_uni()(receive-only), suitable for unidirectional data pushes. - The implementation in
iroh/src/endpoint/connection.rs(lines 862-886) delegates tonoq::Connection, with Iroh providing ergonomic async wrappers around the QUIC protocol. - Both stream types support up to 2⁶² concurrent streams per connection, making them lightweight enough to create on demand for individual operations.
Frequently Asked Questions
What is the difference between open_bi and open_uni in Iroh?
open_bi() creates a bidirectional stream that returns both a SendStream and a RecvStream, allowing you to write data and read responses on the same logical channel. open_uni() creates a unidirectional stream that returns only a SendStream, restricting the flow to one direction from the opener to the peer, who must call accept_uni() to receive it.
How many concurrent streams can an Iroh QUIC connection handle?
According to the source code in connection.rs (lines 63-66), a single QUIC connection can handle up to 2⁶² total streams (the sum of bidirectional and unidirectional streams). This limit is sufficiently large that typical applications can treat streams as virtually unlimited resources, creating them per operation without concern for connection exhaustion.
Does Iroh implement the QUIC stream logic directly?
No, Iroh's Connection type in iroh/src/endpoint/connection.rs provides a thin async wrapper that delegates to the underlying noq crate. The heavy lifting—including frame parsing, flow control, and congestion management—is handled by noq, while Iroh focuses on providing ergonomic APIs and integrating transport concerns like NAT traversal and path selection.
When should I use a unidirectional stream instead of a bidirectional one?
Use unidirectional streams when your communication pattern involves only one-way data flow, such as streaming logs, pushing file chunks, or sending heartbeats. Unidirectional streams reduce resource overhead by allocating only one direction of flow. Use bidirectional streams when you need correlated two-way communication, such as RPC calls where the request and response must be paired on the same logical channel.
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 →