Migration from simple-peer
Coming from PeerJS instead? See Migration from PeerJS — the pitfalls are different.
simple-peer and @metered-ca/peer solve similar problems with different mental models. The biggest delta: simple-peer is 1:1 (one Peer wraps one RTCPeerConnection); @metered-ca/peer is 1:N inside a channel (one MeteredPeer manages every peer in the room).
This page maps simple-peer concepts onto MeteredPeer, then walks a port.
TL;DR — three things to internalize
| If you only read one thing | What it means for your port |
|---|---|
You go from per-pair new Peer() to one peer.join(channel) | Delete your per-target Peer construction loop. The SDK discovers other peers via presence — every channel member becomes a RemotePeer automatically. |
peer.send is server-routed, not WebRTC DataChannel | Same call, different transport. Fine for chat / presence / control messages; for game state or high-frequency data, open a P2P DataChannel via the remote.pc.createDataChannel(...) escape hatch. |
| The SDK auto-reconnects — your reconnect logic is now obsolete | simple-peer's "Peer is single-use, replace on close" pattern is gone. 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 | simple-peer | @metered-ca/peer |
|---|---|---|
| Topology | One Peer = one peer-to-peer link | One MeteredPeer = one channel with N peers |
| Signalling | DIY — you carry SDP + ICE between peers | Managed by the Metered signalling server |
| Initiator role | You set initiator: true/false per Peer | Automatic — perfect negotiation handles it |
peer.signal(data) | Customer hand-routes SDP / ICE | Internal — no signal() call needed |
peer.on("signal", …) | Customer wires the signalling channel | Internal — handled by the SDK |
| Adding media | new Peer({ stream }) | peer.addStream(stream, metadata?) — fans out to every channel member; optional metadata labels the stream so receivers can tell apart camera vs screen vs custom sources |
| Sending data (broadcast) | n/a — simple-peer is 1:1 | peer.send(data) → server-routed broadcast to channel |
| Sending data (directed) | peer.send(data) → DataChannel (P2P) | peer.sendTo(peerId, data) → server-routed direct (different semantics from the simple-peer DC default!) |
| Peer-to-peer DataChannel | Auto-opened | Opt-in via remote.pc.createDataChannel(...) |
| Receiving streams | peer.on("stream", …) | remote.on("stream-added", ({ stream, metadata }) => …) — fires once per remote MediaStream (preferred for tile-per-stream UIs). remote.on("track", …) is also available for per-track handling. |
| Connection failure | Replace the Peer | Auto-reconnect; peer.close() + fresh instance is terminal |
The two deltas that bite people
1. peer.send is server-routed, not P2P
In simple-peer:
peer.send("hello"); // P2P over DataChannel, ~5-30ms
In @metered-ca/peer:
await peer.send("hello"); // Server-routed (Browser → Metered → Browser), ~10-50ms
For chat, presence, control messages, the server-routed model is fine — it works before ICE completes and you don't manage DataChannels yourself.
For game state, large transfers, telemetry, you want the P2P version. Open a real DataChannel via the escape hatch — and wire it to state-change → "connected" so it re-opens automatically across reconcile (the SDK swaps the underlying RTCPeerConnection on transient WS drops, so the OLD RTCDataChannel reference is dead after a swap):
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("game-state", { ordered: false });
dc.onmessage = (ev) => handle(ev.data);
channels.set(remote.id, dc);
});
});
See Data Channels & Low Latency for the full pattern (backpressure, ordered/unordered modes, reliability tradeoffs).
2. Presence-driven peer discovery, not manual connect
In simple-peer, you build a Peer per remote target — you decide who connects to whom and when. In @metered-ca/peer, you join(channel) and the SDK discovers peers via the server's presence event. Every peer in the channel becomes a RemotePeer automatically.
This means you don't have a "build a Peer for each user in this room" loop anymore — the SDK does that.
Porting checklist
- Replace
new SimplePeer({ initiator, stream })withnew MeteredPeer({ apiKey })+peer.addStream(stream). - Delete your custom signalling channel +
peer.signal(data)plumbing — the Metered signalling server replaces it. - Replace
peer.on("stream", …)withremote.on("stream-added", ({ stream, metadata }) => …). If you need per-track granularity (e.g. routing audio vs video separately), useremote.on("track", …)instead. - Replace
peer.send(data)over DataChannel with either:peer.send(data)(server-routed broadcast) — if your messages are app-level / low-frequencyremote.pc.createDataChannel(...)(P2P) — if you need P2P latency for high-frequency data
- Replace per-target Peer construction with a single
peer.join(channel)+peer-joinedlistener. - Replace
peer.destroy()withpeer.close(). - Replace
peer.on("close", …)and "build a new Peer on close" pattern with the auto-reconnect that the SDK provides. Setreconnect: falseonly if you really want the old behaviour (rare).
Worked example
Before — simple-peer
import SimplePeer from "simple-peer";
// Your custom signalling layer
const sig = yourSignallingChannel;
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
function buildPeer(remoteId, initiator) {
const p = new SimplePeer({ initiator, stream: localStream });
p.on("signal", (data) => sig.send({ to: remoteId, payload: data }));
p.on("stream", (s) => attachVideo(remoteId, s));
p.on("data", (chunk) => handle(chunk));
p.on("close", () => removeVideo(remoteId));
return p;
}
const peers = new Map();
sig.on("user-joined", ({ id }) => {
peers.set(id, buildPeer(id, true));
});
sig.on("signal", ({ from, payload }) => {
let p = peers.get(from);
if (!p) {
p = buildPeer(from, false);
peers.set(from, p);
}
p.signal(payload);
});
sig.on("user-left", ({ id }) => {
peers.get(id)?.destroy();
peers.delete(id);
});
After — @metered-ca/peer
import { MeteredPeer } from "@metered-ca/peer";
const peer = new MeteredPeer({ apiKey: "pk_live_…" });
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
peer.addStream(localStream);
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream, metadata }) => attachVideo(remote.id, stream, metadata));
});
peer.on("peer-left", ({ peer: remote }) => {
removeVideo(remote.id);
});
peer.on("data", ({ senderPeerId, data }) => {
handle(senderPeerId, data);
});
await peer.join("room-42");
The custom signalling channel, the per-peer construction, the initiator switch, the SDP/ICE plumbing — all gone. The SDK handles them.
join when you canIf getUserMedia resolves before you join, call peer.addStream(localStream) first. The tracks land in the very first SDP offer — one fewer round-trip than attaching after join (which triggers a renegotiation cycle). Both orders work correctly; this is purely a startup-latency optimization.
peer.addStream(localStream); // ← attach first
await peer.join("room-42"); // ← then join
If you defer getUserMedia until after join (to time the browser permission prompt with user intent), attach when it resolves — that's fine too.
What you keep
getUserMediacalls — same API.<video>element handling — same API.- Your app-level message types and handlers — just move them into the new event names.
- Your reconnect logic, if you had one — though the SDK's built-in is probably better than what you had. See Reconnect Best Practices.
Pitfalls when porting
Sending huge data via
peer.send. simple-peer's DC could carry large binary chunks;peer.sendis server-routed with awelcome.maxMessageSizecap (64 KB default). For large transfers, open a P2P DataChannel.Expecting
peer.sendlatency to match. Server-routed adds a hop. For chat and control messages it's still fast enough; for tight games, switch to DataChannel.Holding
peer.send-equivalent calls beforejoinresolves. In simple-peer, you build the Peer and send when "connect" fires. Here, youawait peer.join(channel)first, then send. Or queue your sends behind thejoinedevent.Forgetting
close()is terminal. A simple-peer-style "destroy and replay on close" pattern won't work —peer.close()can't rejoin. Construct a newMeteredPeerif you really need to.Trusting
fromfields nested in yourdatapayload. simple-peer didn't have envelope-level identity — every protocol you built had its ownfrom. Now use the SDK'ssenderPeerId(server-stamped, trustworthy) instead ofdata.from.replaceTrackpartial failures. simple-peer'sreplaceTrackis single-peer — it either succeeds or throws. MeteredPeer's fans out across every peer, 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 rather than rebuilding the whole fanout: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;
}