Skip to main content

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 thingWhat 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-reconnectspeer.reconnect() is obsoleteWebSocket 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

ConceptPeerJS@metered-ca/peer
Stable per-user IDnew Peer("alice") — you pick the IDMint a JWT with sub: "alice" and the SDK uses that as the peerId
PeerServer signallingOperated by you or PeerJS CloudOperated by Metered (wss://rms.metered.ca)
Discovering other userspeer.call("bob") / peer.connect("bob") — you need their ID firstpeer.join(channel) + presence — the SDK tells you who's there
Receiving a callpeer.on("call", call => call.answer(stream))peer.on("peer-joined", …) + peer.addStream(stream) (fans out)
Receiving dataconn.on("data", …)peer.on("data", …) (server-routed) — or P2P DC via escape hatch
Multiple connectionsOne Peer can have multiple active calls / data connectionsOne MeteredPeer is tied to one channel; for multi-channel use SignallingClient
Reconnectpeer.reconnect() — manualAuto-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:

  1. Both Alice and Bob join a channel named after their pair, e.g. call/${aliceId}-${bobId}.
  2. Whichever joins first waits in the channel; the second triggers peer-joined on the first.
  3. 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_ to tokenProvider + JWT with sub.
  • Replace peer.call(remoteId, stream) with peer.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)) with peer.on("peer-joined", remote => …). There's no "answer" step; both peers attach streams via addStream and the SDK negotiates.
  • Replace peer.on("connection", …) with peer.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 to peer.on("data", …). Note that the SDK's data event arrives with { senderPeerId, data } — use senderPeerId as the trusted sender, not anything inside data.

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.

Ordering tip — attach streams BEFORE join when you can

PeerJS'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 getUserMedia calls
  • Your <video> element handling
  • Any application-level message types you defined

Pitfalls when porting

  1. Trying to "call" someone who isn't in the channel. peer.sendTo(peerId, data) rejects with peer_not_found if the target isn't online. PeerJS would have queued the data on the DataConnection until it opened; the SDK doesn't.

  2. 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).

  3. Trying peer.close() then peer.join(channel). Terminal — construct a fresh instance.

  4. Holding remote.pc references across a reconnect. PeerJS's connection objects survived its reconnect(). The SDK's RemotePeer survives, but the underlying pc doesn't. Re-read it each time you need it.

  5. Wide-open channel patterns. PeerJS's PeerServer (in default config) was wide open by design. The Metered server enforces the JWT's channels claim — 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.

  6. replaceTrack partial failures. PeerJS's MediaConnection.peerConnection.getSenders()[0].replaceTrack(t) operated on a single peer connection — either succeeds or throws. MeteredPeer's peer.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 throws MeteredPeerReplaceTrackError carrying per-peer succeeded and failed lists 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