Skip to main content

Introduction

rtc.io (npm) is a WebRTC library for the browser, paired with a thin Node signaling server. It wraps the messy parts of RTCPeerConnection — perfect negotiation, ICE candidates, transceivers, DataChannel matching, glare resolution — behind an API that mirrors socket.io:

On the name

rtc.io is the npm package name. The project lives at rtcio.dev (docs at docs.rtcio.dev, source on GitHub). The rtc.io web domain is an older, unrelated project we have no affiliation with.

socket.emit("chat", "hello everyone");
socket.on("chat", (msg) => console.log(msg));

Except that emit and on here travel directly between browsers over a peer-to-peer DataChannel — not through the server. The server's only job is to relay setup messages until the peer connection is alive; after that it's out of the data path.

What's in the box

rtc.io is two npm packages:

  • rtc.io — the browser client. Extends socket.io-client, adds RTCPeerConnection orchestration, stream replay for late joiners, broadcast and per-peer DataChannels, transparent flow control.
  • rtc.io-server — a Node signaling server. Extends socket.io, registers a single relay handler for the rtc.io message envelope. Everything else (rooms, presence, app events) is your code.

We also host a free public signaling server at server.rtcio.dev — point your io() URL there to skip the server step entirely while prototyping. Please read the public server caveats before using it for anything beyond a private demo — the public server is shared with everyone using rtc.io, so anyone who joins a room with the same name lands in the same call.

What you get

  • Built on socket.io. rtc.io's client extends socket.io-client's Socket, and rtc.io-server extends socket.io's Server. Every existing socket.io idiom — io(), emit, on, namespaces, rooms on the server, the wire protocol, reconnection — works unchanged. We add peer-to-peer media and DataChannels behind that same API. The credit for the API shape goes to the socket.io team.
  • Standard WebRTC, no surprises. Native RTCPeerConnection under the hood. No SFU, no media server, no custom protocol on the wire. Once connected, your browsers speak DTLS-encrypted SRTP and SCTP straight to each other.
  • Perfect negotiation handled for you. The W3C polite/impolite pattern with stale-answer detection, manual rollback for older browsers, automatic ICE restart on connection failed. Connection failures don't strand calls.
  • Multiple named channels per peer. A built-in ctrl channel for socket.emit, plus any number of named channels — broadcast (socket.createChannel) for everyone, or per-peer (socket.peer(id).createChannel) for one-to-one. Each has its own ordering and retransmit semantics.
  • Streams as first-class. Wrap a MediaStream in RTCIOStream, emit it. Late joiners receive it automatically via the replay registry. Toggle tracks at runtime — transceivers are reused.
  • Backpressure built-in. Per-channel queue budget, high/low watermarks tied to bufferedAmount, drain events. Big payloads don't blow up your tab.

What it isn't

  • Not an SFU. Connections are full-mesh; every browser sends to every other. Great up to ~6–8 peers; for 30+ person rooms or recording, an SFU like mediasoup or LiveKit is the right choice and rtc.io is happy to coexist.
  • Not opinionated about rooms or auth. The server is a relay. Wiring up join-room, presence, history, OAuth — that's your application code (we have an example but it's just an example).
  • Not a polyfill. It assumes a modern browser with full WebRTC support (Chrome, Edge, Firefox, Safari 16+).

For a longer write-up of why we built it, what use cases pulled us toward it, and how it sits next to peerjs, simple-peer, and the SFU ecosystem, see Why rtc.io.

A taste

import io, { RTCIOStream } from "rtc.io";

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

// Local camera/mic.
const local = await navigator.mediaDevices.getUserMedia({
video: true, audio: true,
});
const camera = new RTCIOStream(local);

socket.server.emit("join-room", { roomId: "demo", name: "alice" });
socket.emit("camera", { stream: camera, metadata: { displayName: "Alice" } });

// Remote camera. Metadata you put alongside the stream rides through verbatim.
socket.on("camera", ({ stream, metadata }) => {
document.querySelector("video.remote").srcObject = stream.mediaStream;
label.textContent = metadata.displayName;
});

// Chat.
const chat = socket.createChannel("chat", { ordered: true });
chat.on("msg", (text) => append(text));
chat.emit("msg", "hi");

// Lifecycle.
socket.on("peer-connect", ({ id }) => console.log("peer up", id));
socket.on("peer-disconnect", ({ id }) => console.log("peer gone", id));

That's the entire surface for a working video room. Keep going to Getting started for a complete walkthrough.

Try it without leaving this page

Two browsers, eight lines of rtc.io, peer-to-peer audio + video. Click below to boot the project, then hit the floating Open 2nd tab ↗ button in the corner of the preview to spawn a second peer.

Minimal video call · 60 lines, runnable
Boots a Vite dev server inside the page and connects to server.rtcio.dev. 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>';
});

For the whole reference app — chat, screen-share, file transfer, mobile UI, device pickers — see rtcio.dev (source on GitHub).

Wire compatibility

The 1.x line uses a unified envelope (#rtcio:message) that older 0.x clients don't speak. Pin rtc.io@^1.1.0 and rtc.io-server@^1.1.0 together. Mismatched versions silently drop signaling traffic.

License

MIT.