Skip to main content

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. getUserMediasocket.emit('camera', new RTCIOStream(local)), two <video> elements, that's it.

Minimal video call
Two browsers, peer-to-peer audio + video, no UI library. Mirror this when you want to drop rtc.io into an existing app.
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>';
});

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.

Broadcast chat
One createChannel('chat'), every peer shares it. 30 lines including the DOM.
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 = '';
});

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.

Per-peer ping/pong
Click 'Open 2nd tab ↗', then click 'Ping' on each peer row. Reply round-trips over the same per-peer ctrl DataChannel.
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>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.

File transfer · backpressure handled correctly
Pick a file in tab #1 to send it to tab #2. Progress bar pauses while the buffer drains.
src/main.ts
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> &amp; <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.

Screen share that survives a late joiner
Click 'Share screen' in tab #1 first, THEN hit 'Open 2nd tab ↗' — the share lands immediately, even though it started before tab #2 connected.
src/main.ts
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.

Unordered DataChannel — cursor sync
Move your mouse over the canvas. Each peer's cursor is broadcast over an unreliable + unordered DataChannel — stale frames drop on the floor.
src/main.ts
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.