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
| Event | Payload | When |
|---|---|---|
state-change | { from, to } | Underlying peer-connection state changed. Drives per-peer UI like "connecting…" spinners. |
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 every track of a previously-seen MediaStream has fired its ended event (the remote called removeStream / removeTrack, the sender stopped the track, the device was disconnected, etc.). 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. |
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 diagnosticscreateDataChannel(...)for P2P dataaddEventListenerfor raw RTCPeerConnection events the SDK doesn't bubble up
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));
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, OR wire to state-change → "connected" so you re-grab the PC whenever it changes:
const channels = new Map(); // peerId → currently-open DC
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
channels.get(remote.id)?.close(); // discard stale DC
const dc = remote.pc.createDataChannel("data");
dc.onmessage = (e) => handle(e.data);
channels.set(remote.id, dc);
});
});
This fires once on the initial connect AND once per reconcile cycle — works correctly across reconnects.
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 reference | Survives signalling-WS reconnect? |
|---|---|
The RemotePeer instance itself | Yes (same object across the drop) |
remote.id | Yes |
remote.metadata (peer-level) | Yes (may be refreshed if the remote rotated their JWT) |
remote.state | Yes (transitions to "reconnecting" then back to "connected") |
remote.pc | Object identity changes — re-read after state-change to "connected" |
Stream-level metadata from stream-added event | Yes — re-sent before track re-attachment on the new PC |
MediaStream object you bound to <video>.srcObject | Object 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 |
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
MeteredPeerDataChannel— backpressure-aware wrapper for use with thepcescape hatch- Data Channels & Low Latency
- Reconnect Best Practices