Migration from PeerJS
Coming from simple-peer instead? See Migration from simple-peer — the pitfalls are different.
PeerJS gives you stable per-user peer IDs + signalling (via PeerServer) + a Peer per remote target. @metered-ca/peer keeps the stable-peer-ID idea but replaces the per-target-Peer model with channels + presence.
TL;DR — three things to internalize
| If you only read one thing | What it means for your port |
|---|---|
Channels, not point-to-point. No peer.call(bobId) or peer.connect(bobId) | You join(channel) and the SDK discovers every other peer in it via presence. For 1:1 "call this specific user", use a deterministic channel name like call/${aliceId}-${bobId}. |
Stable peer IDs come from your JWT (sub claim), not from new Peer("alice") | pk_live_ browser keys give random UUIDs. For stable IDs you must mint JWTs server-side with sub: <yourUserId> and pass tokenProvider. |
The SDK auto-reconnects — peer.reconnect() is obsolete | WebSocket reconnect, ICE-restart, and channel-level peer-state reconcile all run automatically. peer.close() is the terminal teardown, not a reconnect trigger. |
Side-by-side
| Concept | PeerJS | @metered-ca/peer |
|---|---|---|
| Stable per-user ID | new Peer("alice") — you pick the ID | Mint a JWT with sub: "alice" and the SDK uses that as the peerId |
| PeerServer signalling | Operated by you or PeerJS Cloud | Operated by Metered (wss://rms.metered.ca) |
| Discovering other users | peer.call("bob") / peer.connect("bob") — you need their ID first | peer.join(channel) + presence — the SDK tells you who's there |
| Receiving a call | peer.on("call", call => call.answer(stream)) | peer.on("peer-joined", …) + peer.addStream(stream) (fans out) |
| Receiving data | conn.on("data", …) | peer.on("data", …) (server-routed) — or P2P DC via escape hatch |
| Multiple connections | One Peer can have multiple active calls / data connections | One MeteredPeer is tied to one channel; for multi-channel use SignallingClient |
| Reconnect | peer.reconnect() — manual | Auto-reconnect with exponential backoff |
The big differences
1. Channels, not point-to-point
In PeerJS, you call one person by ID: peer.call("bob", stream). In @metered-ca/peer, you join a channel that "bob" is also in, and the SDK figures out who's there via presence. You don't address individual peers to initiate a connection — they're already in the channel.
This works well for rooms ("everyone in room-42 talks to everyone else"). For 1:1 calls where you specifically want to call Bob and only Bob, scope the channel to those two users (e.g. call/${aliceId}-${bobId}) and have both join.
2. Stable peer IDs come from your JWT
PeerJS:
new Peer("alice", { host: "your-peer-server.com" });
@metered-ca/peer:
// Backend (your server)
const token = jwt.sign({ sub: "alice", channels: [...], ... }, sk_secret);
// Browser
new MeteredPeer({ tokenProvider: async () => fetchToken() });
pk_live_ keys assign random UUIDs as peerIds — for stable IDs you must use the JWT path. See Authentication.
3. No peer.call(), no peer.connect()
PeerJS users often look for the "how do I initiate a call to a specific peer" method. There isn't one — you don't initiate a connection, you join a channel and the SDK handles connectivity to everyone in it.
If you need to "call Bob" specifically:
- Both Alice and Bob join a channel named after their pair, e.g.
call/${aliceId}-${bobId}. - Whichever joins first waits in the channel; the second triggers
peer-joinedon the first. peer.addStream(stream)from each side sends video to the other.
For an explicit "ring before they pick up" UX, use server-routed send for the invitation (your server can pre-allocate the channel), then have both peers join when accepted.
4. peer.send / peer.sendTo is server-routed by default
PeerJS:
const conn = peer.connect("bob");
conn.on("open", () => conn.send("hi"));
@metered-ca/peer:
await peer.sendTo(bobId, "hi"); // server-routed direct
Same idea, different routing. See routing trade-offs in the MeteredPeer reference.
For PeerJS-style P2P DataChannel data, open one via the escape hatch — and wire it to state-change → "connected" so it re-opens automatically across reconcile. PeerJS's DataConnection objects survived its peer.reconnect(); the SDK's RemotePeer survives reconcile but the underlying RTCDataChannel doesn't, so cached DC handles go stale:
const channels = new Map();
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("state-change", ({ to }) => {
if (to !== "connected") return;
// First time → opens the initial DC.
// After reconcile → OLD DC is dead; this opens a fresh one on the new PC.
channels.get(remote.id)?.close();
const dc = remote.pc.createDataChannel("data");
dc.onmessage = (e) => handle(e.data);
channels.set(remote.id, dc);
});
});
Porting checklist
- If you want stable peer IDs (recommended), switch from
pk_live_totokenProvider+ JWT withsub. - Replace
peer.call(remoteId, stream)withpeer.addStream(stream)(fans out to every peer in the channel). - Replace
peer.connect(remoteId)with nothing —peer.sendTo(remoteId, data)works directly once joined. - Replace
peer.on("call", call => call.answer(stream))withpeer.on("peer-joined", remote => …). There's no "answer" step; both peers attach streams viaaddStreamand the SDK negotiates. - Replace
peer.on("connection", …)withpeer.on("peer-joined", …). The wrapping concept of a "DataConnection" is gone — every channel-member is reachable. - Replace
peer.disconnect()+peer.reconnect()with… nothing. The SDK auto-reconnects.peer.close()is the terminal teardown if you really do want to end the session. - Map your
conn.on("data", …)handlers topeer.on("data", …). Note that the SDK'sdataevent arrives with{ senderPeerId, data }— usesenderPeerIdas the trusted sender, not anything insidedata.
Worked example — 1:1 call
Before — PeerJS
import Peer from "peerjs";
const peer = new Peer(localId);
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
// Initiator
const call = peer.call(remoteId, localStream);
call.on("stream", (remoteStream) => attachVideo(remoteStream));
// Receiver
peer.on("call", (call) => {
call.answer(localStream);
call.on("stream", (remoteStream) => attachVideo(remoteStream));
});
After — @metered-ca/peer
import { MeteredPeer } from "@metered-ca/peer";
// Both sides: derive a deterministic channel name for the pair
const channel = `call/${[localId, remoteId].sort().join("-")}`;
const peer = new MeteredPeer({
tokenProvider: async () => fetchTokenFor(localId, channel),
});
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
peer.addStream(localStream);
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("track", ({ streams }) => attachVideo(streams[0]));
});
await peer.join(channel);
Both sides run identical code. The peer that joins second triggers peer-joined on the first; both have already attached their stream so video flows immediately.
join when you canPeerJS's peer.call(remoteId, stream) couples the stream attachment to the per-target call. With MeteredPeer, you can attach the stream before joining — the tracks then land in the very first SDP offer, saving a renegotiation round-trip. Both orders work; this is purely a startup-latency optimization.
peer.addStream(localStream); // ← attach first
await peer.join(channel); // ← then join
If you defer getUserMedia to time the browser permission prompt with user intent, attach when it resolves — that's fine too.
What you keep
- The mental model of stable per-user IDs (just minted differently)
- Your
getUserMediacalls - Your
<video>element handling - Any application-level message types you defined
Pitfalls when porting
Trying to "call" someone who isn't in the channel.
peer.sendTo(peerId, data)rejects withpeer_not_foundif the target isn't online. PeerJS would have queued the data on the DataConnection until it opened; the SDK doesn't.Trying to multi-call from one MeteredPeer. One MeteredPeer = one channel. To talk to two separate rooms, construct two MeteredPeers, OR use SignallingClient with multiple subscribes (for pub/sub-only, no per-peer media).
Trying
peer.close()thenpeer.join(channel). Terminal — construct a fresh instance.Holding
remote.pcreferences across a reconnect. PeerJS's connection objects survived itsreconnect(). The SDK'sRemotePeersurvives, but the underlyingpcdoesn't. Re-read it each time you need it.Wide-open channel patterns. PeerJS's PeerServer (in default config) was wide open by design. The Metered server enforces the JWT's
channelsclaim — a peer can only subscribe to channels matching its patterns. Make sure your JWT minting sets the right scope, and prefer the narrowest pattern that works.replaceTrackpartial failures. PeerJS'sMediaConnection.peerConnection.getSenders()[0].replaceTrack(t)operated on a single peer connection — either succeeds or throws. MeteredPeer'speer.replaceTrack(oldTrack, newTrack)fans out across every peer in the channel, so a partial failure is a real case (e.g. one peer's codec rejects, the rest succeed). The SDK throwsMeteredPeerReplaceTrackErrorcarrying per-peersucceededandfailedlists so you can converge surgically:import { MeteredPeerReplaceTrackError } from "@metered-ca/peer";
try {
await peer.replaceTrack(oldCam, newCam);
} catch (e) {
if (e instanceof MeteredPeerReplaceTrackError) {
// e.succeeded: peerIds already on newCam — leave alone
// e.failed: [{ peerId, err }] — retry only these or removeStream+addStream
for (const { peerId, err } of e.failed) {
console.warn(`replaceTrack failed for ${peerId}:`, err.message);
}
} else throw e;
}
See also
- Getting Started
MeteredPeerreference- Authentication — minting JWTs with stable
sub - Reconnect Best Practices