str0m: a Sans I/O WebRTC implementation in Rust
A Sans I/O WebRTC implementation in Rust.
At a glance
- What is it?
- str0m implements WebRTC in Rust without doing any network I/O itself: the Rtc instance has no threads and no async tasks, and every byte and deadline comes back to you as output to send or wait on. It fits engineers who want to own the socket, the timers and the event loop, and it is a poor fit for anyone who wants a drop-in RTCPeerConnection.
- Who is it for?
- str0m fits a Rust engineer building a media server, an SFU or a CLI agent who wants to own the UDP socket, the timers and the task scheduling, and who is comfortable with SDP negotiation as an explicit step. It is the wrong choice for a browser-facing application expecting a standard RTCPeerConnection API, for a team that wants the library to handle ICE transport details, or for anyone who needs a WebRTC stack with a supported release cadence.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository received new commits within the last day.
- What is it written in?
- Mainly Rust, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The Rtc instance does no I/O, which is the whole point
str0m is a WebRTC implementation in Rust written in the Sans I/O style. The README states that the Rtc instance itself is not doing any network talking, and furthermore that it has no internal threads or async tasks, and that all operations happen from calls of the public API.
That is a design decision with consequences in both directions. On the good side, there is no hidden runtime to reason about, no internal executor to configure, and no way for the library to fight your application over a task executor. The whole thing is a state machine you drive, which makes it testable and easy to embed in a server that already has its own event loop.
On the other side, you take on work that a conventional stack hides. You own the socket, you own the timers, you own the retransmission schedule, and you own the mapping from the library's output to whatever I/O model your application uses. The README is candid about this, noting that the API is deliberately not a standard RTCPeerConnection API since that is not a great fit for Rust.
The audience is therefore quite specific: people writing an SFU, a media gateway, a CLI agent, or an embedded media component in Rust, who want the protocol logic without the framework assumptions. The README points at BitWHIP, a CLI WebRTC agent written in Rust, as a real project using str0m, which is a better indication of the intended audience than any feature list.
The crate is named str0m, the version in the manifest is 0.24.1, the minimum supported Rust version is 1.85.0, and the edition is 2024. The licence is MIT OR Apache-2.0.
The run loop is a mutate, drain, repeat contract
The README describes one strict contract that the run loop is built around: every mutation of an Rtc instance must be followed by a complete drain of poll_output until it returns Output::Timeout, before the next mutation on the same Rtc. The README calls this the single-mutation invariant.
A mutation is anything that takes a mutable reference to the Rtc, directly or through a handle obtained from it. The README lists the common ones: Rtc::handle_input for feeding a network packet or a timeout, Writer::write and Writer::request_keyframe for sending media, Channel::write for data channel data, SdpApi::apply and the DirectApi methods for negotiation, and the add_local_candidate and add_remote_candidate calls.
The rule is short to state and easy to violate. Doing two mutations back to back without draining in between, or waiting on I/O while the engine still has output queued, leaves the engine in an inconsistent state and produces wrong behavior. The README notes that mutations issued from inside the drain loop are fine, because the loop naturally continues calling poll_output afterward and the invariant holds.
The canonical shape is a six-step loop that is the same whether you are writing sync or async code. Wait for one of the next timeout firing, an incoming network packet, or the application wanting to mutate. Perform that one mutation. Poll poll_output. Handle the output, where Output::Transmit goes on the socket, Output::Event is dispatched to the application, and Output::Timeout records the next deadline. Repeat the poll and handle steps until poll_output returns Output::Timeout, and only then is the engine fully drained. The returned timeout is what you wait on next.
This is the part of the API that will determine whether your integration is pleasant or a source of intermittent bugs, so it is worth reading the README's run loop section carefully before writing any code.
Driving a passive connection: accept an offer, add a candidate
The passive case is where the remote peer makes the media and the initial offer. The README lists the steps needed to open such a connection, and the code is short enough to read as the shape of the whole API.
You instantiate an Rtc with the current time, add a local ICE candidate such as a locally bound UDP port, then accept the incoming offer and get the answer back.
// Instantiate a new Rtc instance.
let mut rtc = Rtc::new(Instant::now());
// Add some ICE candidate such as a locally bound UDP port.
let addr = "1.2.3.4:5000".parse().unwrap();
let candidate = Candidate::host(addr, "udp").unwrap();
rtc.add_local_candidate(candidate);
// Accept an incoming offer from the remote peer
// and get the corresponding answer.
let offer = todo!();
let answer = rtc.sdp_api().accept_offer(offer).unwrap();Three things stand out. The offer comes from outside the library, marked in the example as a todo, because how you transport the signalling is outside the scope of this library. The candidate is created with an explicit protocol string, udp in this case, so the ICE agent is told what kind of transport it is looking at. And the answer comes back from accept_offer as a value, so the flow reads as a sequence of transformations rather than as callbacks.
The candidate address in the example is a literal placeholder address with a fixed port. In a real deployment that is where your bound socket goes, and the candidate you add is the one the engine will later use when it decides where to send.
Note the ordering constraint again. Each of those calls is a mutation, so between them the engine must be drained to a timeout, which is the invariant from the previous section.
The active side builds the offer through a change object
When you make the offer, the flow changes shape. The README calls this the active case: you make the initial offer and wait for a remote answer.
The difference is the SdpApi. The README describes creating an SdpApi so that multiple changes can be made before sending the offer, and the example adds media with a kind, a direction and three further parameters, then applies the changes to get the offer and a pending value.
// Create a `SdpApi`. The change lets us make multiple changes
// before sending the offer.
let mut change = rtc.sdp_api();
// Do some change. A valid OFFER needs at least one "m-line" (media).
let mid = change.add_media(MediaKind::Audio, Direction::SendRecv, None, None, None);
// Get the offer.
let (offer, pending) = change.apply().unwrap();The pending value returned alongside the offer is what ties the answer back to this negotiation. The example applies the answer with rtc.sdp_api().accept_answer(pending, answer), and the README says the pending value is required for that call, which is how the library matches an answer to the offer it belongs to when several negotiations are in flight.
Two constraints are stated in the comments. A valid offer needs at least one m-line, meaning at least one media section, and the media you add is described by kind and direction, with Direction::SendRecv meaning you both send and receive on that section.
The README is explicit that how you transfer the offer to the remote peer is outside the scope for this library, and that you await the answer before applying it. So the library handles the protocol and you handle the signalling, which is the same division the passive example shows from the other side.
Crypto backends are separate crates, and OpenSSL is a deliberate choice
The workspace layout in the Cargo manifest shows how much of str0m is protocol and how much is platform. Alongside the main crate there are crates for the protocol definitions, for the ICE implementation and for a network emulator used in tests, plus a separate fuzz crate that is excluded from the workspace.
Then there are the crypto crates, and this is the part that needs a decision. The workspace lists four of them: aws-lc-rs, rust-crypto, openssl and apple-crypto, plus a wincrypto crate further down. Each is a separate crate published at its own version, so you pick the backend that matches your deployment rather than inheriting one.
The manifest carries a comment block on OpenSSL with the trade-off spelled out. Vendored, meaning a static link from source, ensures version consistency and is easier to build but increases size. Non-vendored, meaning a dynamic link against system libraries, reduces size but may cause compatibility issues. The comment goes further and says to choose based on security and footprint needs, noting that for large deployments sharing OS images, OS updates can be easier for security patching than updating statically linked software.
That is a genuinely useful piece of advice for a media server fleet, and it is the kind of thing a library README usually omits.
The rest of the tooling is visible in the repository entries. There is a cargo_deny.sh script with a deny.toml for dependency policy, a run-fuzz.sh script, a clippy.toml for lint configuration, a .taplo.toml for TOML formatting, a CHANGELOG.md, a docs directory, a tests directory, and an ssl-gen.sh script with an ssl-req.conf for generating the certificates the examples need. The build tooling is thorough for a crate of this size.
Running the examples: a self-signed certificate and a browser warning
The examples are the fastest way to understand what the library expects from you, and the README explains the TLS wrinkle that trips people up.
For a browser to do WebRTC, all traffic must be under TLS. The project ships with a self-signed certificate used for the examples, and the certificate is for the hostname str0m.test, because the .test top level domain should never resolve to a real DNS name.
The chat example shows how to connect multiple browsers together and act as an SFU, multiplexing all traffic over one server UDP socket and using two threads, one for the web server and one for the SFU loop. It is started with:
cargo run --example chatThe README says the log should prompt you to connect a browser to an address on port 3000, and that this will most likely cause a security warning that you must get the browser to accept. That is the self-signed certificate doing its job, and it is a reminder that a real deployment needs a certificate the browser trusts.
The second example is described as roughly illustrating how to receive media data from a browser client. It is single threaded and a bit simpler than the chat example, and the README calls it a good starting point to understand the API:
cargo run --example http-postThe examples directory lists the supporting files, with a chat example and an http-post example, each with its own HTML page, plus a util directory and the certificate and key files. The repository root also has a cert.pem and the two scripts for generating certificates.
The chat example is the more interesting one for a server engineer, since multiplexing many peers over a single UDP socket is the arrangement a real SFU needs, and it is only two threads because the engine does not spawn any of its own.
Where str0m is the wrong tool
The first wrong-tool case is an application that wants a standard WebRTC API. The README says outright that this is deliberately not a standard RTCPeerConnection API, because that is not a great fit for Rust. If your team already has code written against the browser API, or if you want to be able to swap in a different WebRTC implementation later, str0m is the wrong shape.
The second is a team that wants the stack to manage transport. ICE, candidate handling and retransmission timing are present, but the contract is that you supply the socket and the timers and the application loop. If you expected a library that starts a listener and manages connections, the amount of code you will write around it is the surprise.
The third is a project with a video codec pipeline. str0m handles the WebRTC layer, meaning session description, transport, RTP framing and the events that carry media between you and the peer. It does not encode or decode. The README's example writes bytes to a socket and reads media out of events, and the events it lists are mainly incoming media data from the remote peer along with data channel data and statistics. Somewhere else in your system there has to be a codec, and the boundary is at the sample or packet level.
The fourth is a team that wants a supported release cadence. The repository has no GitHub releases, the crate is at 0.24.1, and the changelog lives in the repository rather than on a release page. The last push was on 2026-10-22 and the repository is not archived, so the work is current, but a pre-1.0 crate from an individual maintainer carries upgrade risk that a vendor-backed WebRTC stack does not.
The fifth is anything where the single-mutation invariant is going to be hard to keep. The invariant is not a suggestion, and violating it produces wrong behavior rather than a panic. Codebases with complex shared-state access around the connection object, where several tasks can trigger mutations, are the places where this API asks the most discipline.
Editorial conclusion
str0m fits a Rust engineer building a media server, an SFU or a CLI agent who wants to own the UDP socket, the timers and the task scheduling, and who is comfortable with SDP negotiation as an explicit step. It is the wrong choice for a browser-facing application expecting a standard RTCPeerConnection API, for a team that wants the library to handle ICE transport details, or for anyone who needs a WebRTC stack with a supported release cadence. Before adopting it, read the run loop section of the README in full, confirm your minimum supported Rust version against the manifest, and decide which crypto backend you want, since the workspace ships several and the OpenSSL choice trades binary size against patching cadence.
Frequently asked questions
What does str0m do and what does it leave to me?
It implements WebRTC in a Sans I/O style, so the Rtc instance does no network talking and has no internal threads or async tasks, and all operations happen through the public API calls you make.
What is the single-mutation invariant in str0m?
Every mutation of an Rtc instance must be followed by a complete drain of poll_output until it returns Output::Timeout before the next mutation on the same Rtc, otherwise the engine ends up in an inconsistent state.
How do I run the str0m examples?
Run cargo run --example chat for the multi-browser SFU example, or cargo run --example http-post for the simpler single threaded one, and expect a browser security warning because the examples use a self-signed certificate for the hostname str0m.test.
Which crypto backends does str0m support?
The workspace contains separate crates for aws-lc-rs, rust-crypto, openssl, apple-crypto and wincrypto, and the manifest notes that vendored OpenSSL is easier to build while a dynamic link reduces size but may cause compatibility issues.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/algesten-str0m)Was this useful?