Streams
Sending audio/video in WebRTC means attaching MediaStreamTracks to an RTCRtpSender. rtc.io wraps that with two ergonomic ideas:
RTCIOStream— a typed, identifiable wrapper around aMediaStream.- A replay registry — streams you
emitare remembered, so when peer N joins later they get those same streams without you doing anything.
RTCIOStream
import { RTCIOStream } from "rtc.io";
const local = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const myCamera = new RTCIOStream(local);
The constructor either takes a MediaStream (auto-generates a UUID for id) or (id: string, mediaStream) if you want a stable identifier across reloads. The id survives the wire trip — both peers see the same RTCIOStream.id so you can correlate streams to people.
Two ways to send it:
// Broadcast: emit reaches every connected peer (and replays for late joiners).
socket.emit("camera", myCamera);
// Per-peer: address one peer specifically.
socket.peer(peerId).emit("screen", myCamera);
On the receive side, the handler shape is symmetric:
socket.on("camera", (stream: RTCIOStream) => {
const peerName = (stream as any).peerName; // your app metadata, see below
videoEl.srcObject = stream.mediaStream;
});
How emit of a stream works under the hood
Three things happen the moment you call socket.emit("camera", myCamera):
- Library detects the
RTCIOStreamin your args and treats this as a stream emit (not a ctrl-channel emit). - Stream + event metadata are stored in a registry keyed by stream id. Future peer connections will replay this.
- For every currently connected peer,
addTransceiveris called for each track (audio,video) withdirection: "sendonly"and the underlyingMediaStreamas the associated stream. The browser firesonnegotiationneeded, rtc.io creates a fresh offer, and the transceiver lights up.
When the remote browser receives the resulting tracks, it fires ontrack. rtc.io looks up which RTCIOStream they belong to (via a mid lookup using a small handshake — see the stream-meta payload in How it works) and dispatches your socket.on("camera", ...) handlers with the wrapped stream.
Attaching metadata to a stream
The RTCIOStream doesn't have to be the only arg. socket.emit deep-walks the payload looking for any RTCIOStream; the rest of the object/array shape is preserved verbatim across the wire. Use this to ship app-level metadata (display name, the kind of stream, the source app) alongside the stream — no second ctrl emit needed:
// ✅ The library finds the RTCIOStream nested inside the payload
socket.emit("stream", {
screen: new RTCIOStream(displayStream),
metadata: { userId: "abc123", displayName: "Alice", kind: "screen" },
});
// Receive side mirrors the emitted shape
socket.on("stream", (payload: {
screen: RTCIOStream;
metadata: { userId: string; displayName: string; kind: "camera" | "screen" };
}) => {
video.srcObject = payload.screen.mediaStream;
label.textContent = payload.metadata.displayName;
});
The metadata is stored alongside the stream in the replay registry, so a late joiner receives the same { screen, metadata } payload they would have received if they'd been there from the start. Re-emit with the same RTCIOStream instance and fresh metadata to update — the registry overwrites by stream id.
Things to keep in mind:
- Only
RTCIOStreaminstances are detected, not bareMediaStream— aMediaStreamJSON-serialises to{}and the receiver gets nothing. - Hold the wrapper stable. One
RTCIOStreamper underlying media for the whole session; a fresh wrapper on every emit creates a new stream id and registers a duplicate. - JSON-safe metadata only. Once the stream tokens are swapped in, the payload goes through
JSON.stringify. Functions and class instances (other thanRTCIOStreamitself) won't survive the trip.
Late joiners
If peer A emits a camera, then peer B joins the room afterwards, peer B should see A's camera. Without intervention, B wouldn't — A's emit happened before B existed.
rtc.io handles this with the replay registry. Whenever a new peer connection is created, rtc.io iterates the registry and calls addTransceiver for every previously-emitted stream:
private replayStreamsToPeer(peer: RTCPeer) {
for (const streamKey in this.streamEvents) {
const events = this.streamEvents[streamKey];
const stream = this.getRTCIOStreamDeep(events);
if (stream) this.addTransceiverToPeer(peer, stream);
}
}
This is exactly what late joiners need. You don't write any code for it.
The flip side: if a stream goes away, the registry still has it. Late joiners would receive a dead stream as if it were active. That's what untrackStream is for:
socket.untrackStream(myCamera);
This drops the stream from the registry. Already-connected peers are unaffected; signal them at the application level if you want them to remove the tile (e.g. emit a stop-share event).
Toggling tracks (mute, camera off)
The right way to mute a mic isn't to remove the track; it's to set track.enabled = false. The track stays in the transceiver, the transmission continues at low overhead, and the remote side just sees zeroed-out frames/silence.
local.getAudioTracks().forEach(t => t.enabled = false); // mute mic
local.getVideoTracks().forEach(t => t.enabled = false); // camera off
This won't trigger any signaling. It's purely a browser-side flag.
Swapping tracks (mic / camera switch)
When the user picks a different microphone mid-call, you don't need to rebuild the connection — MediaStream.addTrack/removeTrack triggers RTCIOStream's internal listener, which drives the library to call replaceTrack on the existing RTCRtpSender:
async function switchMic(deviceId: string) {
const fresh = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: deviceId } }
});
const newTrack = fresh.getAudioTracks()[0];
const oldTrack = localStream.getAudioTracks()[0];
if (oldTrack) {
oldTrack.stop();
localStream.removeTrack(oldTrack);
}
localStream.addTrack(newTrack);
// RTCIOStream's `addtrack`/`removetrack` listener fires onTrackChanged.
// The library reuses idle transceivers via replaceTrack — no SDP renegotiation.
}
onTrackChanged is exposed publicly on RTCIOStream if you want to react to remote-side track changes too:
remoteStream.onTrackChanged((stream) => {
console.log("remote tracks now:", stream.getTracks().map(t => t.kind));
});
It returns an unsubscribe function.
Track-added (late tracks)
If a peer adds a new kind of track to an existing stream (e.g. starts with audio only, adds video later), the receiving side fires track-added:
socket.on("track-added", ({ peerId, stream, track }) => {
console.log(peerId, "added a", track.kind, "track to", stream.id);
});
This is an rtc.io reserved event — you can listen but a peer can't spoof it. Useful for "they turned the camera on now" UI changes without your own application-level signaling.
Track-removed (partial departure)
The mirror of track-added: when the WebRTC stack drops a track from a remote stream — the remote ended a screen share, called removeTrack and renegotiated, or stopped a transceiver — the receive side fires track-removed:
socket.on("track-removed", ({ peerId, stream, track }) => {
if (track.kind === "video" && stream.getVideoTracks().length === 0) {
hideVideoTile(peerId);
}
});
The stream argument is the same MediaStream you originally got via socket.on("camera", ...), so you can correlate it back to the same tile. Use this for "they turned the camera off" UI without inventing app-level events.
track-removed is for partial departures — the peer is still connected, they just dropped a track. For the peer leaving entirely, listen on peer-disconnect.
Synthetic streams
If a peer's tracks arrive without an associated MediaStream (rare, can happen with some SFU configurations), rtc.io creates a synthetic one and continues:
[rtc-io] ontrack: no associated stream, created synthetic { peer, trackKind, trackId }
The receive side still fires track-added and the stream still has a fresh id. Most apps don't notice this; it's a robustness fallback.
Screen share
Screen share is just another stream:
const display = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
const screen = new RTCIOStream(display);
socket.emit("screenshare", { id: socket.id, name: userName, stream: screen });
// Stop:
display.getVideoTracks()[0].addEventListener("ended", () => {
socket.untrackStream(screen);
socket.emit("stopScreenShare", { id: socket.id }); // app-level
});
The two emits are intentional: screenshare is the stream announcement (replays to late joiners). stopScreenShare is just a regular ctrl-channel event so already-connected peers can remove the tile. untrackStream removes the stream from the replay registry so future joiners don't see it.
Multiple streams
You can emit more than one stream — rtc.io tracks each by id. A typical layout:
socket.emit("camera", { id: socket.id, camera: cameraStream });
socket.emit("screenshare", { id: socket.id, stream: screenStream });
The receive side gets both via separate on handlers, and both replay to late joiners.
Stats per stream
For diagnostics you can drill into per-peer connection stats:
const stats = await socket.getSessionStats(peerId);
// → { rtt, codecs, inboundRTP[], outboundRTP[], ... }
outboundRTP[].kind lets you see which media is going out and at what bitrate. See Stats for a full tour.
When the stream looks laggy
If you're seeing soft, low-frame-rate, or behind-real-time video — especially when sharing a game or video — it's almost always the browser's default capture and encode settings, not rtc.io. See Stream tuning · why high-motion looks laggy for the four knobs (frameRate constraint, contentHint, encoder maxBitrate, audio DSP) that fix it.
Live examples
Two-tab video call
Late-joiner replay in action
Click Share screen in tab #1 first, then hit Open 2nd tab ↗. The second tab sees the share land instantly — even though it joined after the share started — because socket.emit('screen', stream) registered the stream for replay.