Getting started
In five minutes you'll have two browser tabs talking to each other over a peer-to-peer connection: video, audio, and a broadcast chat channel.
We'll skip running a server for now and use the public server.rtcio.dev signaling endpoint. Self-hosting is one npm install away and is covered in the server section.
server.rtcio.dev is a single shared, unauthenticated namespace — every app pointing at it lands in the same room namespace. Always use a hard-to-guess roomId (a UUID, or 16+ random characters via crypto.randomUUID()). Short or predictable names like demo, test, or team-standup will almost certainly collide with other people running the same tutorial. The snippets below use crypto.randomUUID() so they're collision-safe out of the box. Read the public server caveats before using it for anything beyond a private experiment.
Install
The client is one npm package:
npm install rtc.io
If you're using a CDN (no build step), esm.sh serves rtc.io with its bare-specifier dependencies (socket.io-client, etc.) resolved for the browser:
<script type="module">
import io, { RTCIOStream } from "https://esm.sh/rtc.io";
// ...
</script>
file://Browsers treat file:// as an opaque origin: ESM imports from CDNs and getUserMedia are both blocked. Serve the page over HTTP — e.g. python3 -m http.server or npx serve — and open http://localhost:<port>/. localhost counts as a secure context, so the camera/mic prompt works.
Minimum viable peer connection
A complete two-tab demo. Save as index.html, open it in two tabs, you'll see your camera in both and audio flowing both ways.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>rtc.io minimal demo</title>
<style>
body { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 16px; background: #0a0908; }
video { width: 100%; aspect-ratio: 16/9; background: #1a1a1a; border-radius: 8px; }
</style>
</head>
<body>
<video id="local" autoplay playsinline muted></video>
<video id="remote" autoplay playsinline></video>
<script type="module">
import io, { RTCIOStream } from "https://esm.sh/rtc.io";
// Hard-to-guess room id is essential on the shared public server.
// First tab generates one; second tab reads it from `?room=...`.
const params = new URLSearchParams(location.search);
let ROOM = params.get("room");
if (!ROOM) {
ROOM = crypto.randomUUID();
// Drop the room id into the URL so you can copy it into a second tab.
history.replaceState(null, "", `?room=${ROOM}`);
}
const socket = io("https://server.rtcio.dev", {
iceServers: [{ urls: "stun:stun.l.google.com:19302" }],
});
const local = await navigator.mediaDevices.getUserMedia({
video: true, audio: true,
});
document.getElementById("local").srcObject = local;
const camera = new RTCIOStream(local);
socket.server.emit("join-room", { roomId: ROOM, name: "guest" });
socket.emit("camera", camera);
socket.on("camera", (remote) => {
document.getElementById("remote").srcObject = remote.mediaStream;
});
</script>
</body>
</html>
Serve the directory (python3 -m http.server 8080) and open http://localhost:8080/ in two tabs (or two browsers). Grant camera/mic access in each.
What just happened, in order:
io(...)opens a socket.io connection toserver.rtcio.dev. Behind it livesManager+Socketfromsocket.io-client, with rtc.io'sRTCPeerConnectionorchestration layered on top.socket.server.emit("join-room", ...)is the only application-level event the demo backend understands — it joins the socket.io room and tells every existing peer to start an offer to the newcomer.- The first tab to load establishes the room. When the second tab joins, both tabs run the perfect-negotiation handshake against each other, transparent to your code.
- Once the peer connection is alive,
socket.emit("camera", new RTCIOStream(local))adds your local stream's tracks assendonlytransceivers. The other tab'ssocket.on("camera")handler fires with anRTCIOStreamwrapper around the remoteMediaStream.
Note that the signaling server never sees your media or your chat traffic. Once the offer/answer/ICE handshake completes, it's not in the data path.
What socket.emit actually does
rtc.io's Socket overrides emit so the same call has three different routings depending on what you pass:
| You pass… | It goes via… |
|---|---|
An event name + an RTCIOStream | The RTCPeerConnection's transceivers (becomes a media track) |
An internal event (prefix #rtcio:) | The signaling server (offers, answers, candidates) |
| Anything else | The ctrl DataChannel — every connected peer, peer-to-peer |
So socket.emit("chat", "hi") is a peer-to-peer broadcast over a DataChannel. It does not touch the server.
For the same reason socket.on("chat", ...) listens to that DataChannel (and to the per-peer listener registry — see socket.peer(...)). It's not a socket.io event listener; rtc.io intercepts those names.
If you want to talk to the actual signaling server (e.g. application-level events the server routes for you), use socket.server.emit("foo", ...) — that's the explicit escape hatch. We use it for join-room because rooms are a server concern.
Adding chat
Append a chat box to the demo:
const chat = socket.createChannel("chat", { ordered: true });
chat.on("msg", (text) => console.log("peer says:", text));
document.querySelector("input").addEventListener("change", (e) => {
chat.emit("msg", e.target.value);
});
createChannel opens a broadcast DataChannel: every peer (and any peer that joins later) shares it. Both sides have to call createChannel("chat") for the channel to exist between them — otherwise sends are dropped at the SCTP layer.
The ordered: true flag forces in-order delivery (the SCTP default for new channels). Set it to false if you'd rather have lower latency at the cost of out-of-order arrivals.
Detecting peers
socket.on("peer-connect", ({ id }) => console.log("peer up:", id));
socket.on("peer-disconnect", ({ id }) => console.log("peer gone:", id));
peer-connect fires when the peer's ctrl DataChannel opens — that's the signal that broadcast channels and socket.emit traffic will reach them. peer-disconnect fires symmetrically when the peer connection is torn down (manual leave, ICE failure, tab close), but only if peer-connect already fired — so you can safely use these events to balance acquire/release patterns.
These events are reserved: peers can't spoof them. See Reserved events for the full list.
What about the lobby / room logic?
The minimal demo above uses server.rtcio.dev, which has the bare minimum room logic baked in: join-room joins a socket.io room, presence is announced, and #rtcio:init-offer is fanned out to existing peers. That's enough for a video room.
For anything more — auth, presence persistence, custom rooms — you'll run your own server. It's about a 30-line file.
See the full reference app
If you'd rather skip the local setup and just look at the production-shaped version — chat, screen share, file transfer, mobile UI, password-protected rooms — it's running live at rtcio.dev, with the source on GitHub.
Or start with the 60-line version
Same room, no React, no router, no production polish — just getUserMedia and socket.emit('camera', new RTCIOStream(...)). The code is below; click Run live to open it in a real StackBlitz tab so the camera/mic prompts come from the embed origin, not from this docs site.
A non-media broadcast channel
If you only need a peer-to-peer chat, presence indicator, or shared whiteboard state, you don't have to touch getUserMedia at all. socket.createChannel('chat') is a broadcast DataChannel — every peer in the room shares it, late joiners are auto-included.
Next
- Tutorial: Build a video room — guided end-to-end, with the public server.
- How it works — what's actually happening behind
emit. - Why rtc.io — the design choices, the trade-offs, the comparison with peerjs / simple-peer / SFUs.
- Perfect negotiation — the reason your offers don't collide.
- Server quickstart — when you outgrow the public server.
The library repo ships an AGENTS.md — a single-file primer that documents the API, the patterns, and the common pitfalls in a form a language model can absorb in one read. Hand it over and your assistant will stop guessing at the API.