Skip to main content

RemotePeer

What peer.remotePeers and the peer-joined event give you. One RemotePeer instance per remote peer currently in your joined channel.

You don't construct these — MeteredPeer creates them as peers join the channel.

What you can read​

remote.id // string — server-assigned or the peer's JWT `sub` claim
remote.metadata // Record<string, unknown> | undefined — the peer's `peerMetadata` from their JWT
remote.state // "idle" | "connecting" | "connected" | "reconnecting" | "closed"
remote.polite // boolean — perfect-negotiation tie-breaker; you rarely need this
remote.pc // RTCPeerConnectionLike — escape hatch, see below

metadata — what to put in it​

peerMetadata is set on the JWT used to mint the peer's connection. Typical fields: userId, username, avatarUrl, role. The server stamps it onto presence events and (optionally) onto broadcast messages.

// Server-side, when you mint a JWT for "alice":
jwt.sign({
sub: "user_alice",
channels: ["room-42"],
peerMetadata: { username: "Alice", avatarUrl: "https://…/alice.png" },
}, secret, ...);

// Client-side:
peer.on("peer-joined", ({ peer: remote }) => {
console.log(`${remote.metadata.username} joined`);
showAvatar(remote.metadata.avatarUrl);
});

pk_live_ keys don't have peerMetadata — it requires a backend-minted JWT. See Authentication.

Don't trust metadata for security. Your backend signed it, so the values came from you originally, but a peer with a leaked JWT could keep using it. Use server-side authorization (channel patterns in the JWT) for access decisions, not client-side metadata.

metadata may refresh during reconcile. remote.metadata is readonly for customer immutability, but if the remote peer rotated their JWT during a signalling-WS disconnect (and reconnected with a fresh JWT carrying updated peerMetadata), the SDK refreshes the value before firing state-change → "connected" on reconcile. No dedicated peer-metadata-changed event fires — read it fresh whenever you need the latest value rather than caching the result of an initial read.

peer.on("peer-joined", ({ peer: remote }) => {
remote.on("state-change", ({ to }) => {
if (to === "connected") {
// Re-read remote.metadata here if you display usernames / avatars —
// it may have changed across the reconcile.
updateNameTag(remote.id, remote.metadata?.username);
}
});
});

What you can do​

send(data) → Promise<void>​

Shorthand for peer.sendTo(remote.id, data). Server-routed via the wire-protocol send frame. See the routing trade-offs on MeteredPeer.

await remote.send({ whisper: "private" });

on(event, handler) · off(event, handler) · once(event, handler)​

Chainable.

Events​

EventPayloadWhen
state-change{ from, to }Underlying peer-connection state changed. Drives per-peer UI like "connecting…" spinners.
connection-reset—The SDK replaced this peer's underlying RTCPeerConnection after a signalling reconnect. remote.pc already points at the fresh connection when this fires — re-create data channels and re-attach raw pc listeners here (the event carries no payload). Requires SDK 1.2.0+.
track{ track, streams, metadata? }Remote sent a new media track. metadata (a StreamMetadata bag) is the per-track / per-stream label the remote attached at addStream / addTrack time. Use it to tell apart camera vs screen vs custom sources.
stream-added{ stream, metadata? }Fires once per MediaStream the remote sends, when its first track arrives. Preferred over track when you want a stream-level (not track-level) handler — most call UIs render one tile per stream.
stream-removed{ stream }Fires when a previously-seen MediaStream has no live tracks left at your end — the remote called removeStream / removeTrack, stopped the track, or the device disconnected. Detection covers both browser signals: the stream's removetrack (sender-side removal) and the track's ended (stopped transceiver / closed connection). Symmetric with stream-added. Not fired during reconcile — see note below.
data-channel{ channel: RTCDataChannel }Remote opened a DataChannel via remote.pc.createDataChannel(...).
negotiation-error{ err }SDP negotiation flow (createOffer / setRemoteDescription) threw. Mostly ignorable — the SDK already handles recovery — with one terminal exception: err.name === "IceRestartExhaustedError" means the SDK's automatic ICE-restart budget for this peer is spent and the connection won't recover on its own. Recover by waiting for the next signalling-level reconnect (which replaces the connection) or by closing the peer and constructing a fresh MeteredPeer. Branch on err.name, never on message text.
ice-candidate-error{ err }An inbound ICE candidate was rejected. Usually benign (stale candidate from a previous network).

track vs stream-added — which to use​

Most call UIs render one video tile per remote MediaStream. For that, stream-added is what you want:

peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream, metadata }) => {
const tile = document.createElement("video");
tile.autoplay = true;
tile.playsInline = true;
tile.dataset.streamId = stream.id;
if (metadata?.role === "screen") tile.classList.add("screen-share");
document.querySelector("#grid").appendChild(tile);
tile.srcObject = stream;
});

remote.on("stream-removed", ({ stream }) => {
document.querySelector(`[data-stream-id="${stream.id}"]`)?.remove();
});
});

Use the lower-level track event when you need per-track control — e.g. attaching audio tracks to a separate <audio> element from the video, or laying out individual tracks differently from how they're grouped into streams.

remote.on("track", ({ track, streams, metadata }) => {
if (track.kind === "audio") attachToAudioPipeline(track);
if (track.kind === "video") attachToVideoTile(streams[0], metadata);
});

Reconcile note for stream-added / stream-removed​

When the signalling WS reconnects, the SDK swaps each survivor's underlying RTCPeerConnection for a fresh one. The remote's tracks arrive again on the new PC, so stream-added fires again — but the MediaStream object is a new one (same stream.id, different object identity). If your customer code cached srcObject = stream and held that reference, you need to re-bind:

(Edge case: if the remote actually removed a stream during your disconnect window, you'll see neither a stream-removed for it nor a stream-added for the same id on reconcile — the stream just disappears from the new presence snapshot. Diff your stream-id map against remote.id-stream-id pairs you've previously seen if you need to detect this.)

const tilesByStreamId = new Map<string, HTMLVideoElement>();

remote.on("stream-added", ({ stream }) => {
// Look up by stream.id (stable across reconcile), re-bind to the NEW MediaStream
let tile = tilesByStreamId.get(stream.id);
if (!tile) {
tile = document.createElement("video");
tile.autoplay = true;
document.querySelector("#grid").appendChild(tile);
tilesByStreamId.set(stream.id, tile);
}
tile.srcObject = stream;
});

stream-removed is suppressed during reconcile — the old PC closes its tracks but the customer doesn't see false "they left" signals. The fresh stream-added on the new PC is the canonical re-bind point.

state-change — per-peer connection state​

remote.on("state-change", ({ to }) => {
if (to === "connected") hideLoadingSpinner(remote.id);
if (to === "reconnecting") showLoadingSpinner(remote.id);
if (to === "closed") removeTile(remote.id);
});

to === "reconnecting" covers two cases:

  • WebRTC-level reconnect (ICE failed/disconnected, the SDK is running the ICE-restart ladder)
  • Signalling-level reconnect (the WS dropped, the SDK is reconciling peers on reconnect)

Both surface the same way so your UI doesn't have to distinguish.

The pc escape hatch​

remote.pc exposes the underlying RTCPeerConnection for things the SDK doesn't surface directly:

  • getStats() for connection diagnostics
  • createDataChannel(...) for P2P data
  • addEventListener for raw RTCPeerConnection events the SDK doesn't bubble up
// Creating side (exactly one per pair — the impolite one):
if (!remote.polite) {
const dc = remote.pc.createDataChannel("game-state", { ordered: false });
dc.onopen = () => console.log("DC open to", remote.id);
dc.onmessage = (ev) => handleGameTick(JSON.parse(ev.data));
}

// Receiving side — the same channel arrives on the data-channel event:
remote.on("data-channel", ({ channel }) => {
channel.onmessage = (ev) => handleGameTick(JSON.parse(ev.data));
});

Two important warnings​

1. remote.pc changes on reconnect.

When the signalling WS reconnects, the SDK silently swaps each survivor's RTCPeerConnection for a fresh one. Your RemotePeer reference stays valid, but its pc property now points at a different object. If you stored const pc = remote.pc somewhere, that variable still points at the old (now-closed) PC.

The right pattern: re-read remote.pc every time you need it — and for things you attach to the PC (data channels, raw addEventListener listeners), attach on peer-joined and re-attach on connection-reset, which fires each time the SDK swaps the connection (SDK 1.2.0+):

const channels = new Map(); // peerId → the channel exchanged with that peer

function wireDataChannel(remote, dc) {
channels.get(remote.id)?.close(); // discard stale DC
dc.onmessage = (e) => handle(e.data);
channels.set(remote.id, dc);
}

function maybeCreateChannel(remote) {
if (remote.polite) return; // exactly one creator per pair
wireDataChannel(remote, remote.pc.createDataChannel("data"));
}

peer.on("peer-joined", ({ peer: remote }) => {
remote.on("data-channel", ({ channel }) => wireDataChannel(remote, channel)); // receiving side
maybeCreateChannel(remote); // initial connection
remote.on("connection-reset", () => maybeCreateChannel(remote)); // after each swap
});

The impolite side creates the channel on peer-joined — channel creation is what starts the connection, so don't gate it on state-change → "connected" (a data-only app never reaches "connected" until a channel exists) — and re-creates it on each connection-reset; the other side receives the same channel via data-channel, so one channel carries both directions.

2. Don't mutate pc.setConfiguration({ iceServers }).

The SDK validates iceServers against an allowlist (stun: / stuns: / turn: / turns: schemes only, size caps on URLs and credentials) before passing them to the constructor. Calling pc.setConfiguration({ iceServers: hostile }) bypasses all of that. There's no way for the SDK to detect or revert the change post-hoc.

If you need different TURN configuration per peer, mint per-user JWTs with different metadata.iceServers claims instead.

What survives a reconnect​

Customer-held referenceSurvives signalling-WS reconnect?
The RemotePeer instance itselfYes (same object across the drop)
remote.idYes
remote.metadata (peer-level)Yes (may be refreshed if the remote rotated their JWT)
remote.stateYes (transitions to "reconnecting" then back to "connected")
remote.pcObject identity changes — re-read it fresh each time you need it; connection-reset fires when it's replaced
Stream-level metadata from stream-added eventYes — re-sent before track re-attachment on the new PC
MediaStream object you bound to <video>.srcObjectObject identity changes — stream.id stable, but it's a new MediaStream. Re-bind on each stream-added event
RTCDataChannel from remote.pc.createDataChannel(...)No — tied to the old PC; re-open on connection-reset
Customer-held listeners on remote.pc (via addEventListener)No — old PC is closed; listeners on the new PC must be re-attached

For the full reconnect playbook including the DataChannel-reopening pattern, see Reconnect Best Practices and Data Channels & Low Latency.

toJSON() — safe for logging​

console.log(remote.toJSON()); // { id, state } — safe to send to your log pipeline

Doesn't include metadata (may contain PII) or pc (cycles).

See also​