Skip to main content

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.

Heads-up about the public server

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>
Don't open the file with 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.

index.html
<!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:

  1. io(...) opens a socket.io connection to server.rtcio.dev. Behind it lives Manager + Socket from socket.io-client, with rtc.io's RTCPeerConnection orchestration layered on top.
  2. 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.
  3. 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.
  4. Once the peer connection is alive, socket.emit("camera", new RTCIOStream(local)) adds your local stream's tracks as sendonly transceivers. The other tab's socket.on("camera") handler fires with an RTCIOStream wrapper around the remote MediaStream.

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 RTCIOStreamThe RTCPeerConnection's transceivers (becomes a media track)
An internal event (prefix #rtcio:)The signaling server (offers, answers, candidates)
Anything elseThe 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.

Minimal video · src/main.ts
Eight lines of rtc.io plus two <video> elements. Click 'Open 2nd tab ↗' inside the preview to call yourself.
src/main.ts
import io, { RTCIOStream } from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

// setupRoom() reads ?room=… from the URL or mints a fresh UUID, picks a
// guest-XXXX display name, and renders the "Open 2nd tab" button in the
// corner so you can spawn a peer in one click. See src/room.ts.
const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Minimal video call · room <code>${ROOM}</code></h1>
<p><small>Click <strong>Open 2nd tab ↗</strong> in the corner to spawn a peer.</small></p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<video id="local" autoplay playsinline muted style="width:100%;border-radius:8px;background:#000;aspect-ratio:16/10"></video>
<video id="remote" autoplay playsinline style="width:100%;border-radius:8px;background:#000;aspect-ratio:16/10"></video>
</div>
<p id="status" style="margin-top:12px"><small>Connecting…</small></p>
</div>`;

const localEl = document.getElementById('local') as HTMLVideoElement;
const remoteEl = document.getElementById('remote') as HTMLVideoElement;
const status = document.getElementById('status')!;

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 });
localEl.srcObject = local;
const camera = new RTCIOStream(local);

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// You can ship app metadata alongside the stream — the library walks args
// looking for any RTCIOStream and preserves the rest of the shape verbatim.
socket.emit('camera', { stream: camera, metadata: { displayName: NAME } });

socket.on('camera', (payload: { stream: RTCIOStream; metadata: { displayName: string } }) => {
remoteEl.srcObject = payload.stream.mediaStream;
status.innerHTML = `<small>Connected · streaming P2P from ${payload.metadata.displayName}</small>`;
});

socket.on('peer-connect', ({ id }) => console.log('peer joined', id));
socket.on('peer-disconnect', ({ id }) => {
console.log('peer left', id);
status.innerHTML = '<small>Peer left. Open another tab to reconnect.</small>';
});

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.

Broadcast chat · src/main.ts
A 30-line chat using one createChannel('chat'). Open in multiple tabs to see the broadcast in action.
src/main.ts
import io from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Broadcast chat · room <code>${ROOM}</code></h1>
<p><small>Every peer (and any peer that joins later) shares one DataChannel.</small></p>
<div id="log" style="height:280px;overflow:auto;background:#0a0908;border:1px solid var(--line);border-radius:8px;padding:10px;font-family:ui-monospace,monospace;font-size:13px"></div>
<form id="form" style="display:flex;gap:8px;margin-top:10px">
<input id="msg" placeholder="say hi…" autocomplete="off" />
<button type="submit">Send</button>
</form>
<p style="margin-top:10px"><small>Joined as <code>${NAME}</code> · click <strong>Open 2nd tab ↗</strong> to chat with yourself.</small></p>
</div>`;

const log = document.getElementById('log')!;
const form = document.getElementById('form') as HTMLFormElement;
const msg = document.getElementById('msg') as HTMLInputElement;

const append = (line: string, dim = false) => {
const row = document.createElement('div');
row.textContent = line;
if (dim) row.style.opacity = '0.55';
log.appendChild(row);
log.scrollTop = log.scrollHeight;
};

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// One broadcast channel, every peer shares it. Late joiners are auto-included
// because the library replays `_channelDefs` on each new peer connection.
const chat = socket.createChannel('chat', { ordered: true });

chat.on('msg', (m: { name: string; text: string }) => {
append(`${m.name}: ${m.text}`);
});

socket.on('peer-connect', ({ id }) => append(`${id.slice(-4)} joined`, true));
socket.on('peer-disconnect', ({ id }) => append(`${id.slice(-4)} left`, true));

form.addEventListener('submit', (e) => {
e.preventDefault();
const text = msg.value.trim();
if (!text) return;
chat.emit('msg', { name: NAME, text });
append(`you: ${text}`);
msg.value = '';
});

Next

Working with a coding assistant?

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.