Examples
Six self-contained projects, one per concept. Each shows the source inline so you can scan it without leaving the page; click Run live on any of them to open it in a real StackBlitz tab — that's where camera/mic prompts, fullscreen, and the like work properly.
All examples connect to the public signaling server at server.rtcio.dev. Each preview ships with an Open 2nd tab ↗ button in the corner — one click spawns a second peer in the same room, so you can actually see the two ends connect without copy-pasting URLs.
1 · Minimal video call
The 60-line version of rtcio-web. getUserMedia → socket.emit('camera', new RTCIOStream(local)), two <video> elements, that's it.
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>';
});
2 · Broadcast chat (no media)
If you only need a peer-to-peer chat, presence indicator, or shared whiteboard state, you don't need getUserMedia at all. socket.createChannel('chat') is a broadcast DataChannel — every peer in the room shares it; late joiners are auto-included.
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 = '';
});
3 · Per-peer messaging (RPC pattern)
socket.peer(id).emit('ping', payload) sends to one peer; the receiver replies via socket.peer(payload.from).emit('pong', ...). The same shape works for one-to-one chat, RPC, leader election, and per-peer auth handshakes.
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>Per-peer messaging · room <code>${ROOM}</code></h1>
<p><small>RPC over <code>socket.peer(id).emit/on</code> — message goes to one peer, not all.</small></p>
<div id="peers" style="display:flex;flex-direction:column;gap:8px"></div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>. Click <strong>Open 2nd tab ↗</strong> to bring a peer online.</small></p>
</div>`;
const peersBox = document.getElementById('peers')!;
const renderPeer = (id: string) => {
const row = document.createElement('div');
row.id = `peer-${id}`;
row.style.cssText = 'display:flex;gap:8px;align-items:center;padding:10px;background:rgba(0,0,0,.25);border:1px solid var(--line);border-radius:8px';
row.innerHTML = `
<code style="flex:1">peer ${id.slice(-6)}</code>
<button data-ping="${id}">Ping</button>
<span data-status="${id}" style="opacity:.7">—</span>`;
peersBox.appendChild(row);
};
const dropPeer = (id: string) => document.getElementById(`peer-${id}`)?.remove();
const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
socket.server.emit('join-room', { roomId: ROOM, name: NAME });
// Library lifecycle event — fires when the ctrl DataChannel to the peer opens,
// which is the moment `socket.peer(id).emit` becomes deliverable.
socket.on('peer-connect', ({ id }) => {
renderPeer(id);
// Send the new peer our hello on connect.
socket.peer(id).emit('hello', { name: NAME });
// Listen for their replies to our pings.
socket.peer(id).on('pong', (data: { rtt: number }) => {
document.querySelector(`[data-status="${id}"]`)!.textContent =
`pong · ${data.rtt.toFixed(1)} ms`;
});
});
socket.on('peer-disconnect', ({ id }) => dropPeer(id));
// Global handlers — fire for messages from ANY peer.
socket.on('hello', (m: { name: string }) => console.log('hello from', m.name));
socket.on('ping', function (this: any, payload: { sentAt: number; from: string }) {
// Reply directly to the sender.
socket.peer(payload.from).emit('pong', {
rtt: performance.now() - payload.sentAt,
});
});
peersBox.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const id = target.dataset.ping;
if (!id) return;
socket.peer(id).emit('ping', { sentAt: performance.now(), from: socket.id });
document.querySelector(`[data-status="${id}"]`)!.textContent = 'sent…';
});
4 · File transfer with backpressure
Custom per-peer ordered DataChannel + 16 KB chunks + the send() / 'drain' flow-control contract. The same approach scales to multi-GB files without OOMing the tab — the library's queue budget is the safety net.
import io, { RTCIOChannel } 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>File transfer · room <code>${ROOM}</code></h1>
<p><small>Per-peer ordered DataChannel · respects backpressure via <code>send() === false</code> & <code>'drain'</code>.</small></p>
<input id="file" type="file" />
<progress id="prog" max="100" value="0" style="width:100%;margin-top:10px;display:none"></progress>
<p id="status"><small>Click <strong>Open 2nd tab ↗</strong> to bring a peer online.</small></p>
<div id="received" style="margin-top:14px;display:flex;flex-direction:column;gap:8px"></div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>.</small></p>
</div>`;
const fileInput = document.getElementById('file') as HTMLInputElement;
const prog = document.getElementById('prog') as HTMLProgressElement;
const status = document.getElementById('status')!;
const received = document.getElementById('received')!;
const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
socket.server.emit('join-room', { roomId: ROOM, name: NAME });
const channels = new Map<string, RTCIOChannel>();
socket.on('peer-connect', ({ id }) => {
// Both sides call createChannel('file'); negotiated:true means each end
// describes the same SCTP stream id in its initial SDP, so the channel is
// open without a DC-OPEN handshake.
const ch = socket.peer(id).createChannel('file', { ordered: true });
channels.set(id, ch);
attachReceiver(ch);
status.innerHTML = `<small>Peer ${id.slice(-4)} ready · pick a file to send.</small>`;
});
socket.on('peer-disconnect', ({ id }) => {
channels.delete(id);
if (channels.size === 0) status.innerHTML = '<small>No peers connected.</small>';
});
interface FileMeta { tid: string; name: string; size: number; mime: string }
function attachReceiver(channel: RTCIOChannel) {
let state: { meta: FileMeta; chunks: ArrayBuffer[]; bytes: number } | null = null;
channel.on('meta', (meta: FileMeta) => {
state = { meta, chunks: [], bytes: 0 };
});
channel.on('data', (chunk: ArrayBuffer) => {
if (!state) return;
state.chunks.push(chunk);
state.bytes += chunk.byteLength;
});
channel.on('eof', () => {
if (!state) return;
const blob = new Blob(state.chunks, { type: state.meta.mime });
const url = URL.createObjectURL(blob);
const row = document.createElement('a');
row.href = url;
row.download = state.meta.name;
row.textContent = `📥 ${state.meta.name} (${(blob.size/1024).toFixed(1)} KB) — click to download`;
row.style.cssText = 'color:var(--accent);text-decoration:underline';
received.appendChild(row);
state = null;
});
}
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
if (channels.size === 0) {
alert('No peers connected — click "Open 2nd tab ↗" first.');
return;
}
prog.style.display = 'block';
prog.value = 0;
const tid = crypto.randomUUID();
const CHUNK = 16 * 1024;
for (const [, channel] of channels) {
channel.emit('meta', { tid, name: file.name, size: file.size, mime: file.type });
}
let sent = 0;
for (let off = 0; off < file.size; off += CHUNK) {
const buf = await file.slice(off, off + CHUNK).arrayBuffer();
for (const [, channel] of channels) {
// send() returning false means the chunk was queued. Wait for the
// 'drain' event before pushing more — this is the entire backpressure
// contract.
if (!channel.send(buf)) {
await new Promise<void>((r) => channel.once('drain', () => r()));
}
}
sent += buf.byteLength;
prog.value = Math.round((sent / file.size) * 100);
}
for (const [, channel] of channels) channel.emit('eof', { tid });
status.innerHTML = `<small>Sent <strong>${file.name}</strong> to ${channels.size} peer(s).</small>`;
});
5 · Late-joiner stream replay
socket.emit('screen', stream) registers the stream so any peer that joins afterward gets it automatically. socket.untrackStream(stream) removes it from the registry when the share ends.
import io, { RTCIOStream } 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>Late-joiner stream replay · room <code>${ROOM}</code></h1>
<p>
<small>Click <strong>Share screen</strong> in tab #1, then hit <strong>Open 2nd tab ↗</strong>.<br>
Tab #2 sees the screen share immediately even though it joined late — the library
replays registered streams to every new peer.</small>
</p>
<button id="share">Share screen</button>
<button id="stop" disabled>Stop sharing</button>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:14px">
<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 style="margin-top:10px"><small>You are <code>${NAME}</code>.</small></p>
</div>`;
const localEl = document.getElementById('local') as HTMLVideoElement;
const remoteEl = document.getElementById('remote') as HTMLVideoElement;
const shareBtn = document.getElementById('share') as HTMLButtonElement;
const stopBtn = document.getElementById('stop') as HTMLButtonElement;
const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
socket.server.emit('join-room', { roomId: ROOM, name: NAME });
let myStream: RTCIOStream | null = null;
shareBtn.addEventListener('click', async () => {
const display = await navigator.mediaDevices.getDisplayMedia({ video: true });
myStream = new RTCIOStream(display);
localEl.srcObject = display;
// emit() is enough — late joiners auto-receive this stream because the
// library keeps a replay registry keyed by the stream's id.
socket.emit('screen', myStream);
shareBtn.disabled = true;
stopBtn.disabled = false;
display.getVideoTracks()[0].addEventListener('ended', () => stopBtn.click());
});
stopBtn.addEventListener('click', () => {
if (!myStream) return;
myStream.mediaStream.getTracks().forEach((t) => t.stop());
// untrackStream drops it from the replay registry so peers joining AFTER
// we stop sharing don't see a dead stream attached.
socket.untrackStream(myStream);
myStream = null;
localEl.srcObject = null;
shareBtn.disabled = false;
stopBtn.disabled = true;
});
socket.on('screen', (s: RTCIOStream) => {
remoteEl.srcObject = s.mediaStream;
});
6 · Unordered, lossy DataChannel (cursor sync)
Pass { ordered: false, maxRetransmits: 0 } to createChannel. The SCTP stream becomes unreliable + unordered — perfect for cursor positions, pose tracking, game state, anything where the next packet is more useful than the last one.
import io, { type RTCIOBroadcastChannel } 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>Unordered DataChannel · room <code>${ROOM}</code></h1>
<p><small>Move your mouse over the dark canvas. <code>{ ordered: false, maxRetransmits: 0 }</code> means
the latest position wins — stale frames don't queue up.</small></p>
<div id="canvas" style="position:relative;height:380px;background:#0a0908;border:1px solid var(--line);border-radius:8px;overflow:hidden;cursor:crosshair">
</div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>.</small></p>
</div>`;
const canvas = document.getElementById('canvas')!;
const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
socket.server.emit('join-room', { roomId: ROOM, name: NAME });
// ordered: false + maxRetransmits: 0 = unreliable, unordered SCTP — this
// is the right shape for cursor positions, game state, etc. The library
// uses the same negotiated:true scheme internally; both sides match by name.
const cursors: RTCIOBroadcastChannel = socket.createChannel('cursors', {
ordered: false,
maxRetransmits: 0,
});
const dots = new Map<string, HTMLDivElement>();
const dotFor = (id: string) => {
let d = dots.get(id);
if (d) return d;
d = document.createElement('div');
d.style.cssText = 'position:absolute;width:14px;height:14px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent);pointer-events:none;transition:transform 60ms linear;transform:translate(-50%,-50%)';
canvas.appendChild(d);
dots.set(id, d);
return d;
};
cursors.on('move', (m: { id: string; x: number; y: number }) => {
const d = dotFor(m.id);
d.style.left = m.x + 'px';
d.style.top = m.y + 'px';
});
socket.on('peer-disconnect', ({ id }) => {
dots.get(id)?.remove();
dots.delete(id);
});
let last = 0;
canvas.addEventListener('mousemove', (e) => {
const now = performance.now();
if (now - last < 16) return; // rough 60fps cap before backpressure does it for us
last = now;
const r = canvas.getBoundingClientRect();
cursors.emit('move', { id: socket.id, x: e.clientX - r.left, y: e.clientY - r.top });
});
7 · The full reference app
For a production-shaped React + Vite app that uses every feature above plus device pickers, mobile UI, password rooms, and image-paste in chat — the demo runs live, not in a sandbox:
How these examples work
The source for each example is rendered inline on this page. Clicking Run live opens a real StackBlitz tab — StackBlitz's WebContainer runtime runs a real Node.js process (including npm install and Vite) directly in your browser. Camera/mic prompts come from the StackBlitz origin, not docs.rtcio.dev, so all the WebRTC features behave the same way they would on your own host.
The <StackBlitz> component on this site uses @stackblitz/sdk's openProject API to post the inline file map at click time — no GitHub repo per example, no API keys, no docs build step.