How to Use Datagrams in iroh QUIC Connections: Unreliable Messaging Guide
iroh exposes QUIC application datagrams through the Connection type, providing read_datagram() for receiving and send_datagram() for transmitting small, unreliable packets ideal for low-latency signaling.
iroh is a peer-to-peer networking library that builds on the QUIC protocol via the noq library, offering both reliable streams and unreliable datagrams. While streams guarantee ordered delivery, datagrams in iroh QUIC connections provide a lightweight, unordered transport primitive perfect for heartbeats, NAT coordination, and loss-tolerant messages. This guide covers the complete API for sending and receiving datagrams, including buffer management and practical examples from the official repository.
What Are QUIC Datagrams in iroh?
QUIC supports application datagrams—small, unreliable, unordered packets delivered within a single QUIC packet. In iroh, the Connection type wraps a noq::Connection and exposes this primitive through a simple async API. According to the source code in iroh/src/endpoint/connection.rs (lines 860–927), the datagram methods forward directly to the underlying QUIC implementation, which handles encryption, fragmentation, and delivery (or discarding if the peer does not support datagrams).
Because datagrams bypass congestion control and ordering guarantees, they may be lost, reordered, or duplicated. They are distinct from the reliable bidirectional (open_bi) and unidirectional (open_uni) streams also available on the Connection.
The iroh Datagram API
The Connection type provides several methods for datagram handling, each serving different flow-control needs.
Reading Incoming Datagrams
To receive data, use read_datagram(), which returns a future that yields a ReadDatagram<'_> containing the next available datagram:
while let Ok(msg) = conn.read_datagram().await {
let text = String::from_utf8(msg.into())?;
println!("Received: {}", text);
}
This method should be called in a loop until the connection closes. Each call awaits the next available packet without allocating a stream.
Sending Datagrams
For fire-and-forget transmission, use send_datagram(data), which immediately queues the data for transmission:
conn.send_datagram(bytes.into())?;
Important: If the send buffer is full, this method drops the oldest pending datagram to make room for the new one. For payloads where loss is acceptable—such as periodic heartbeats—this behavior is desirable.
Handling Back-Pressure
When preserving older datagrams is critical, use send_datagram_wait(data). Unlike the non-blocking variant, this returns a future that resolves only once buffer space is available, preventing drops:
conn.send_datagram_wait(bytes.into()).await?;
This approach is safer for control signals where dropping a prior message would break protocol synchronization.
Checking Size Limits and Buffer Space
Datagrams are limited by the current path MTU. Before sending, check max_datagram_size() to determine the current per-peer limit (typically a few kilobytes, though it may shrink during path changes):
let max_size = conn.max_datagram_size();
To query available capacity without sending, use datagram_send_buffer_space(), which reports how many bytes can be sent without causing drops:
let space = conn.datagram_send_buffer_space();
Complete Working Examples
The iroh repository provides full working examples demonstrating both sides of a datagram conversation.
Server Example (listen-unreliable.rs)
The listener example in iroh/examples/listen-unreliable.rs accepts incoming QUIC connections and echoes back any received datagrams:
use iroh::{Endpoint, RelayMode, SecretKey, endpoint::presets};
use n0_error::Result;
const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";
#[tokio::main]
async fn main() -> Result<()> {
let endpoint = Endpoint::builder(presets::N0)
.secret_key(SecretKey::generate())
.alpns(vec![EXAMPLE_ALPN.to_vec()])
.relay_mode(RelayMode::Default)
.bind()
.await?;
endpoint.online().await;
while let Some(incoming) = endpoint.accept().await {
let conn = incoming.accept()?.await?;
tokio::spawn(async move {
while let Ok(msg) = conn.read_datagram().await {
let txt = String::from_utf8(msg.into()).unwrap();
println!("received: {txt}");
let reply = format!("hi! you connected to {}. bye", conn.id());
conn.send_datagram(reply.into_bytes().into()).unwrap();
}
});
}
Ok(())
}
Source: https://github.com/n0-computer/iroh/blob/main/iroh/examples/listen-unreliable.rs
Client Example (connect-unreliable.rs)
The connector example in iroh/examples/connect-unreliable.rs establishes a connection, sends a single datagram, and awaits the response:
use std::{net::SocketAddr, str::FromStr};
use clap::Parser;
use iroh::{Endpoint, EndpointAddr, RelayMode, SecretKey, endpoint::presets};
use iroh_base::TransportAddr;
use n0_error::Result;
const EXAMPLE_ALPN: &[u8] = b"n0/iroh/examples/0";
#[derive(Parser)]
struct Cli {
#[clap(long)] endpoint_id: iroh::EndpointId,
#[clap(long, value_parser, num_args = 1.., value_delimiter = ' ')] addrs: Vec<SocketAddr>,
#[clap(long)] relay_url: iroh::RelayUrl,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Cli::parse();
let endpoint = Endpoint::builder(presets::N0)
.secret_key(SecretKey::from_bytes(&rand::random()))
.alpns(vec![EXAMPLE_ALPN.to_vec()])
.relay_mode(RelayMode::Default)
.bind()
.await?;
endpoint.online().await;
let remote_addrs = args
.addrs
.into_iter()
.map(TransportAddr::Ip)
.chain(std::iter::once(TransportAddr::Relay(args.relay_url)));
let remote = EndpointAddr::from_parts(args.endpoint_id, remote_addrs);
let conn = endpoint.connect(remote, EXAMPLE_ALPN).await?;
let payload = format!("{} says hello!", endpoint.id());
conn.send_datagram(payload.clone().into_bytes().into())?;
let reply = conn.read_datagram().await?;
println!("received: {}", String::from_utf8(reply.into()).unwrap());
Ok(())
}
Source: https://github.com/n0-computer/iroh/blob/main/iroh/examples/connect-unreliable.rs
Both examples use the same ALPN (b"n0/iroh/examples/0"), generate temporary SecretKeys, and rely on the default relay mode for NAT traversal.
When to Use Datagrams vs. Streams
Choose the right transport primitive based on reliability requirements:
- Datagrams (
read_datagram,send_datagram): Best for low-latency signaling, heartbeats, NAT-punching coordination, or any payload that tolerates loss. They are unordered and may be dropped by the network or buffer management. - Streams (
open_bi,open_uni): Required for reliable, ordered data transfer such as file transfers, RPC requests, or any protocol requiring guaranteed delivery.
Summary
- The
Connectiontype iniroh/src/endpoint/connection.rs(lines 860–927) exposes QUIC datagram functionality throughread_datagram(),send_datagram(), andsend_datagram_wait(). read_datagram()awaits incoming packets;send_datagram()transmits immediately (potentially dropping old data);send_datagram_wait()preserves ordering by waiting for buffer space.- Always check
max_datagram_size()before sending, as the limit varies with path MTU changes. - Reference
listen-unreliable.rsandconnect-unreliable.rsfor complete client/server implementations using the ALPNb"n0/iroh/examples/0". - Reserve datagrams for loss-tolerant signaling; use
open_bioropen_unistreams when reliability is required.
Frequently Asked Questions
What is the maximum size of a datagram in iroh?
Call Connection::max_datagram_size() to determine the current limit, which is typically a few kilobytes but may shrink when the path MTU changes. The underlying QUIC implementation handles fragmentation into single packets, but exceeding this limit will result in transmission failure.
Can datagrams be lost or reordered?
Yes. Datagrams are explicitly unreliable and unordered. They may be lost in transit, duplicated, or arrive out of order relative to other datagrams or streams. For guaranteed delivery, use the reliable stream API (open_bi or open_uni).
How do I prevent datagrams from being dropped when the buffer is full?
Use send_datagram_wait() instead of send_datagram(). The former returns a future that resolves only when buffer space is available, ensuring older datagrams are not evicted to make room for new ones.
Where can I find the implementation details?
The high-level API is implemented in iroh/src/endpoint/connection.rs (lines 860–927), which wraps the noq::Connection type. For relay-specific handling (relevant when traversing NATs), see iroh/src/socket/transports/relay/actor.rs, which manages queuing and forwarding of datagrams to relay servers.
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 →