Sans-I/O WebRTC implementation in Rust
Sponsored with 💖 by
Silver Sponsors:
Bronze Sponsors:
AdrianEddy
Table of Content
RTC is a pure Rust implementation of WebRTC using a sans-I/O architecture. Unlike traditional WebRTC libraries, RTC separates protocol logic from I/O operations, giving you complete control over networking, threading, and async runtime integration.
Sans-I/O (without I/O) is a design pattern where the library handles protocol logic but you control all I/O operations. Instead of the library performing network reads and writes directly, you feed it network data and it tells you what to send.
Benefits:
- 🚀 Runtime Independent - Works with tokio, async-std, smol, or blocking I/O
- 🎯 Full Control - You control threading, scheduling, and I/O multiplexing
- 🧪 Testable - Protocol logic can be tested without real network I/O
- 🔌 Flexible - Easy integration with existing networking code
There is no ambient clock, either: every method that needs the time takes an Instant from you. That is what makes a
whole session reproducible in a test without a socket or a sleep.
[dependencies]
rtc = "0.21"RTC requires Rust edition 2024.
All cryptography goes through a pluggable RTCCryptoProvider. Two built-in providers ship with the crate and the
Cargo features are additive — enabling both builds successfully, and ring is preferred when both are on:
# Default: the ring provider, no C toolchain required.
rtc = "0.21"
# aws-lc-rs instead (pulls in the aws-lc-sys C toolchain).
rtc = { version = "0.21", default-features = false, features = ["crypto-aws-lc-rs"] }There is no process-global provider to install. The provider is selected per peer connection through
SettingEngine::set_crypto_provider, so two peer connections in one process may use different ones. Applications
needing OpenSSL, a FIPS-validated module, an HSM, or a platform backend can implement the public traits themselves and
validate the result against the bundled conformance suite (rtc_crypto::conformance::assert_provider, behind the
test-support feature).
The event loop is the sansio::Protocol trait implemented by RTCPeerConnection — bring the trait into scope with
use rtc::sansio::Protocol; or none of these methods will resolve.
| Method | Direction | What it does |
|---|---|---|
handle_read(TaggedBytesMut) |
in | Feed one received UDP/TCP datagram, tagged with its 5-tuple and arrival instant |
poll_write() -> Option<TaggedBytesMut> |
out | Take the next packet to put on the wire; drain until None |
poll_read() -> Option<TaggedRTCMessage> |
out | Take the next inbound RTP/RTCP/data-channel message for the application |
poll_event() -> Option<RTCPeerConnectionEvent> |
out | Take the next state change or notification |
poll_timeout() -> Option<Instant> |
out | Next deadline for retransmissions, keepalives and ICE checks |
handle_timeout(Instant) |
in | Tell the connection a deadline has passed |
handle_write(TaggedRTCMessage) |
in | Queue an outbound RTP/RTCP/data-channel message |
close() |
in | Shut the connection down |
handle_event is also part of the trait, but RTCEvent is currently uninhabited: no value of it can be constructed,
so there is nothing to call it with. It exists so the signature is already right when the first inbound event variant
is added.
Order matters only in that poll_write should be drained after anything that could have produced output —
handle_read, handle_timeout, handle_write, and the negotiation calls all queue packets rather than sending them.
use rtc::peer_connection::RTCPeerConnectionBuilder;
use rtc::peer_connection::configuration::RTCConfigurationBuilder;
use rtc::peer_connection::event::{RTCPeerConnectionEvent, RTCTrackEvent};
use rtc::peer_connection::state::RTCPeerConnectionState;
use rtc::peer_connection::message::{RTCMessage, TaggedRTCMessage};
use rtc::peer_connection::sdp::RTCSessionDescription;
use rtc::peer_connection::transport::RTCIceServer;
use rtc::shared::{TaggedBytesMut, TransportContext, TransportProtocol};
use rtc::sansio::Protocol;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket;
use bytes::BytesMut;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// 1. Build the peer connection. `build` takes the instant the session starts.
let mut pc = RTCPeerConnectionBuilder::new()
.with_configuration(
RTCConfigurationBuilder::new()
.with_ice_servers(vec![RTCIceServer {
urls: vec!["stun:stun.l.google.com:19302".to_string()],
..Default::default()
}])
.build(),
)
.build(Instant::now())?;
// 2. Signaling: every description call also takes the current instant.
let offer = pc.create_offer(None)?;
pc.set_local_description(Instant::now(), offer.clone())?;
// send `offer.sdp` over your signaling channel, then:
// let answer = RTCSessionDescription::answer(answer_sdp)?;
// pc.set_remote_description(Instant::now(), answer)?;
// 3. You own the socket.
let socket = UdpSocket::bind("0.0.0.0:0").await?;
let local_addr = socket.local_addr()?;
let mut buf = vec![0u8; 2000];
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(86400);
loop {
// Drain everything the connection wants to send.
while let Some(msg) = pc.poll_write() {
socket.send_to(&msg.message, msg.transport.peer_addr).await?;
}
// Drain state changes and notifications.
while let Some(event) = pc.poll_event() {
match event {
RTCPeerConnectionEvent::OnConnectionStateChangeEvent(state) => {
println!("connection state: {state}");
if state == RTCPeerConnectionState::Failed {
return Ok(());
}
}
RTCPeerConnectionEvent::OnTrack(RTCTrackEvent::OnOpen(init)) => {
println!("new track: {}", init.track_id);
}
// RTCPeerConnectionEvent is #[non_exhaustive].
_ => {}
}
}
// Drain inbound application messages. `poll_read` yields a *tagged* message:
// `now` is when the packet was observed at the socket, not when you drained it.
while let Some(TaggedRTCMessage { now, message }) = pc.poll_read() {
match message {
RTCMessage::RtpPacket(track_id, _packet) => {
println!("RTP on track {track_id} at {now:?}");
}
RTCMessage::RtcpPacket(receiver_id, _packets) => {
println!("RTCP for receiver {receiver_id:?}");
}
RTCMessage::DataChannelMessage(channel_id, _msg) => {
println!("data-channel message on {channel_id:?}");
}
// RTCMessage is #[non_exhaustive].
_ => {}
}
}
// Wait for whichever comes first: a packet or the next protocol deadline.
let timeout = pc.poll_timeout().unwrap_or(Instant::now() + DEFAULT_TIMEOUT);
let delay = timeout.saturating_duration_since(Instant::now());
if delay.is_zero() {
pc.handle_timeout(Instant::now())?;
continue;
}
tokio::select! {
biased;
_ = tokio::time::sleep(delay) => {
pc.handle_timeout(Instant::now())?;
}
Ok((n, peer_addr)) = socket.recv_from(&mut buf) => {
pc.handle_read(TaggedBytesMut {
now: Instant::now(),
transport: TransportContext {
local_addr,
peer_addr,
ecn: None,
transport_protocol: TransportProtocol::UDP,
},
message: BytesMut::from(&buf[..n]),
})?;
}
}
}
}Because RTC does no I/O, it also does no candidate gathering: you bind the sockets, so you tell the connection
which local candidates exist. Build them with the rtc-ice candidate constructors and hand each one to
add_local_candidate:
use rtc::peer_connection::RTCPeerConnectionBuilder;
use rtc::peer_connection::transport::{
CandidateConfig, CandidateHostConfig, RTCIceCandidate, RTCIceCandidateInit,
};
use std::time::Instant;
fn gather(pc: &mut rtc::peer_connection::RTCPeerConnection) -> Result<(), Box<dyn std::error::Error>> {
let candidate = CandidateHostConfig {
base_config: CandidateConfig {
network: "udp".to_owned(),
address: "192.168.1.100".to_string(),
port: 8080,
component: 1,
..Default::default()
},
..Default::default()
}
.new_candidate_host()?;
pc.add_local_candidate(RTCIceCandidate::from(&candidate).to_json()?)?;
// An EMPTY candidate string is the end-of-gathering sentinel: it moves the ICE gathering
// state to `Complete` and emits `OnIceGatheringStateChangeEvent(Complete)`. Nothing else
// reaches that state, so send it once you have added every local candidate.
pc.add_local_candidate(RTCIceCandidateInit::default())?;
Ok(())
}Remote candidates arrive over your signaling channel and go in through add_remote_candidate, which requires a remote
description to have been set first. For srflx and relay candidates, set RTCIceCandidateInit::url to the
STUN/TURN server the candidate was gathered from so it is attributed correctly in getStats.
poll_read is the throttle. Undrained data-channel messages leave bytes in SCTP's reassembly queue, which lowers the
receiver-window credit advertised in every SACK, which tells the peer to slow down. Declining to call poll_read is
how you apply back-pressure; resume when the application catches up. Media is never throttled this way — RTP arrives
over SRTP and is subject to none of SCTP's flow control.
- ✅ ICE (Interactive Connectivity Establishment) - NAT traversal with STUN/TURN, plus mDNS candidates
- ✅ DTLS (Datagram Transport Layer Security) - Encryption for media and data
- ✅ SCTP (Stream Control Transmission Protocol) - Reliable data channels
- ✅ RTP/RTCP - Real-time media transport and control
- ✅ SDP (Session Description Protocol) - Offer/answer negotiation, Unified Plan
- ✅ Data Channels - Bidirectional peer-to-peer data transfer, in-band (DCEP) and out-of-band
- ✅ Media Tracks - Audio/video transmission
- ✅ Trickle ICE - Progressive candidate gathering, and ICE restart
- ✅ ICE over TCP - Active and passive
- ✅ Simulcast & SVC - Simulcast and scalable video coding
- ✅ RTX & FEC - Retransmission and forward error correction
- ✅ Statistics -
getStatsper the W3C WebRTC Statistics API - ✅ Interceptors - Composable RTP/RTCP pipeline (NACK, TWCC, reports, bandwidth estimation)
- ✅ Pluggable crypto -
ringoraws-lc-rsbuilt in, or bring your own provider
A provider-neutral cryptographic API. Every cryptographic operation in the stack now goes through the new
rtc-crypto crate rather than a hard-wired backend:
RTCCryptoProviderbundles anRTCCryptooperations trait and anRTCRandomCSPRNG.RingProviderandAwsLcRsProvidership built in, and an application can supply its own — OpenSSL, a FIPS-validated module, an HSM, a platform backend — by implementing the public traits.- No library code resolves a default provider.
crypto::default_provider()is called in exactly one place, at peer-connection construction, where the application either supplied one viaSettingEngine::set_crypto_provideror gets the feature-selected built-in. A--no-default-featuresbuild now fails at configuration time instead of deep inside a handshake. crypto-ringandcrypto-aws-lc-rsare additive. Enabling both builds and is covered in CI; previously the combination was acompile_error!, which broke otherwise valid builds whenever Cargo feature unification pulled in both.- A reusable conformance suite (
rtc_crypto::conformance::assert_provider) validates any implementation against the same RFC vectors the built-ins pass. - Certificates are provider-neutral:
RTCCertificate::generate,generate_from_signing_keyandfrom_pkcs8. Non-exportable HSM/KMS keys are supported.
Performance. Key schedules moved off the per-packet path: SRTP keys its MACs once per context (~40% faster per
RTCP packet), DTLS CBC keys its record MACs at epoch setup (~4-7% faster), AES-CTR is batched (~9-10% faster on a
1200-byte payload), and the built-in RTCRandom no longer reads the OS per record (DTLS GCM record encryption went
from 1.015 µs to 262 ns). Every per-packet benchmark is at parity with or faster than the pre-migration baseline.
Behaviour change. MediaEngine::register_default_codecs no longer registers video/ulpfec, because the receive
path does not recover media from ULPFEC packets. This changes the default offer's SDP: payload type 116 is gone.
Register it explicitly with MIME_TYPE_ULP_FEC if you need it.
See CHANGELOG.md for the full list, and docs/crypto-provider-migration.md for before/after migration examples.
The repository includes examples demonstrating various use cases:
- data-channels-offer-answer - Complete data channel setup with signaling
- data-channels-flow-control - Applying back-pressure to a fast sender
- trickle-ice - Progressive candidate exchange
- ice-restart - Recovering a connection after a network change
- perfect-negotiation - Glare-free renegotiation with rollback
- reflect - Echo server that reflects media back to the sender
- save-to-disk-vpx - Receive and save VP8/VP9 video
- play-from-disk-vpx - Send VP8/VP9 video from disk
- simulcast - Receive 3 simulcast encodings in one track
- broadcast - Broadcast a video to multiple peers
- stats - Statistical information about a PeerConnection
Run one with its registered example name:
cargo run --example data-channels-offer
cargo run --example data-channels-answerRTC is built from composable crates, each implementing a specific protocol:
RTC
Media
Interceptor
DataChannel
RTP
RTCP
SRTP
SCTP
DTLS
Crypto
mDNS
STUN
TURN
ICE
SDP
Shared
use rtc::data_channel::{RTCDataChannelId, RTCDataChannelInit};
use rtc::peer_connection::RTCPeerConnection;
use rtc::peer_connection::event::{RTCDataChannelEvent, RTCPeerConnectionEvent};
use rtc::sansio::Protocol;
use std::time::Instant;
fn open(pc: &mut RTCPeerConnection) -> Result<RTCDataChannelId, Box<dyn std::error::Error>> {
let init = RTCDataChannelInit {
ordered: true,
max_retransmits: None,
..Default::default()
};
// The handle borrows `pc`, so keep the id to reach the channel again later.
let id = pc.create_data_channel("my-channel", Some(init))?.id();
Ok(id)
}
fn on_open(pc: &mut RTCPeerConnection, id: RTCDataChannelId) -> Result<(), Box<dyn std::error::Error>> {
// Only send once the channel is open: an in-band channel's SCTP stream is
// established by the DCEP handshake, and sending before then is rejected.
if let Some(mut dc) = pc.data_channel(id) {
dc.send_text(Instant::now(), "Hello, WebRTC!")?;
}
Ok(())
}
fn drive(pc: &mut RTCPeerConnection) -> Result<(), Box<dyn std::error::Error>> {
while let Some(event) = pc.poll_event() {
if let RTCPeerConnectionEvent::OnDataChannel(RTCDataChannelEvent::OnOpen(id)) = event {
on_open(pc, id)?;
}
}
Ok(())
}use rtc::media_stream::MediaStreamTrack;
use rtc::peer_connection::RTCPeerConnection;
use rtc::rtp_transceiver::RTCRtpSenderId;
use rtc::rtp_transceiver::rtp_sender::{
RTCRtpCodec, RTCRtpCodingParameters, RTCRtpEncodingParameters, RtpCodecKind,
};
fn add_video(
pc: &mut RTCPeerConnection,
ssrc: u32,
) -> Result<RTCRtpSenderId, Box<dyn std::error::Error>> {
let track = MediaStreamTrack::new(
"stream-id".to_string(),
"track-id".to_string(),
"Camera".to_string(),
RtpCodecKind::Video,
vec![RTCRtpEncodingParameters {
rtp_coding_parameters: RTCRtpCodingParameters {
ssrc: Some(ssrc),
..Default::default()
},
codec: RTCRtpCodec::default(),
..Default::default()
}],
);
Ok(pc.add_track(track)?)
}use rtc::peer_connection::RTCPeerConnection;
use rtc::statistics::StatsSelector;
use std::time::Instant;
fn report(pc: &mut RTCPeerConnection) {
// The instant is passed in rather than read from the clock, so a stats
// snapshot is as reproducible as the rest of the session.
let report = pc.get_stats(Instant::now(), StatsSelector::None);
if let Some(stats) = report.peer_connection() {
println!("data channels opened: {}", stats.data_channels_opened);
}
for stream in report.inbound_rtp_streams() {
println!(
"ssrc {}: {} packets received",
stream.received_rtp_stream_stats.rtp_stream_stats.ssrc,
stream.received_rtp_stream_stats.packets_received
);
}
}WebRTC requires an external signaling channel (e.g. WebSocket, HTTP) to exchange offers and answers. Note that every description call takes the current instant:
use rtc::peer_connection::RTCPeerConnection;
use rtc::peer_connection::sdp::RTCSessionDescription;
use std::time::Instant;
fn negotiate(
pc: &mut RTCPeerConnection,
answer_sdp: String,
) -> Result<(), Box<dyn std::error::Error>> {
let offer = pc.create_offer(None)?;
pc.set_local_description(Instant::now(), offer.clone())?;
// send `offer.sdp` over your signaling channel, receive the answer, then:
let answer = RTCSessionDescription::answer(answer_sdp)?;
pc.set_remote_description(Instant::now(), answer)?;
Ok(())
}This implementation follows these specifications:
| Layer | Specification |
|---|---|
| API | W3C WebRTC 1.0, W3C WebRTC Statistics |
| Signaling | RFC 9429 (JSEP), RFC 8866 (SDP), RFC 8843 (BUNDLE), RFC 8853 (Simulcast) |
| Connectivity | RFC 8445 (ICE), RFC 8838 (Trickle ICE), RFC 8839 (SDP for ICE), RFC 8489 (STUN), RFC 8656 (TURN) |
| Security | RFC 6347 (DTLS 1.2), RFC 5764 (DTLS-SRTP), RFC 3711 (SRTP) |
| Data | RFC 9260 (SCTP), RFC 8261 (SCTP over DTLS), RFC 8831 (Data Channels), RFC 8832 (DCEP) |
| Media | RFC 3550 (RTP/RTCP), RFC 4588 (RTX), RFC 8285 (Header Extensions), RFC 5761 (RTP/RTCP Mux) |
- API Documentation - Complete API reference
- Examples - Working code examples
- Sans-I/O Pattern - Detailed explanation of the sans-I/O design
- WebRTC for the Curious - Comprehensive WebRTC guide
# Build the library
cargo build
# Run tests
cargo test
# Build with the aws-lc-rs crypto provider instead of ring
cargo build --no-default-features --features crypto-aws-lc-rs
# Build documentation
cargo doc --open
# Run an example
cargo run --example data-channels-answerThis project follows Semantic Versioning:
- Patch (
0.x.Y): Bug fixes and internal improvements with no public API changes. - Minor (
0.X.0): Backwards-compatible additions or deprecations to the public API. - Major (
X.0.0): Breaking changes to the public API.
While the version is 0.x, the minor version acts as the major — i.e., a minor bump may include breaking changes. Once
1.0.0 is released, full semver stability guarantees apply.
Pre-release versions are published with the following suffixes, in order of increasing stability:
-alpha.N: Early preview. API is unstable and may change significantly.-beta.N: Feature-complete for the release. API may still have minor changes.-rc.N: Release candidate. No further API changes are expected unless critical issues are found.
For example: 1.0.0-alpha.1 → 1.0.0-beta.1 → 1.0.0-rc.1 → 1.0.0.
Contributions are welcome! Please feel free to submit a Pull Request.
This project is licensed under either of:
- MIT License (LICENSE-MIT or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
at your option.
Special thanks to all contributors and the WebRTC-rs community for making this project possible.


