Skip to main content

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 thingWhat 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 DataChannelSame 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 obsoletesimple-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

Conceptsimple-peer@metered-ca/peer
TopologyOne Peer = one peer-to-peer linkOne MeteredPeer = one channel with N peers
SignallingDIY — you carry SDP + ICE between peersManaged by the Metered signalling server
Initiator roleYou set initiator: true/false per PeerAutomatic — perfect negotiation handles it
peer.signal(data)Customer hand-routes SDP / ICEInternal — no signal() call needed
peer.on("signal", …)Customer wires the signalling channelInternal — handled by the SDK
Adding medianew 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:1peer.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 DataChannelAuto-openedOpt-in via remote.pc.createDataChannel(...)
Receiving streamspeer.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 failureReplace the PeerAuto-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 }) with new MeteredPeer({ apiKey }) + peer.addStream(stream).
  • Delete your custom signalling channel + peer.signal(data) plumbing — the Metered signalling server replaces it.
  • Replace peer.on("stream", …) with remote.on("stream-added", ({ stream, metadata }) => …). If you need per-track granularity (e.g. routing audio vs video separately), use remote.on("track", …) instead.
  • Replace peer.send(data) over DataChannel with either:
    • peer.send(data) (server-routed broadcast) — if your messages are app-level / low-frequency
    • remote.pc.createDataChannel(...) (P2P) — if you need P2P latency for high-frequency data
  • Replace per-target Peer construction with a single peer.join(channel) + peer-joined listener.
  • Replace peer.destroy() with peer.close().
  • Replace peer.on("close", …) and "build a new Peer on close" pattern with the auto-reconnect that the SDK provides. Set reconnect: false only 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.

Ordering tip — attach streams BEFORE join when you can

If 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

  • getUserMedia calls — 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

  1. Sending huge data via peer.send. simple-peer's DC could carry large binary chunks; peer.send is server-routed with a welcome.maxMessageSize cap (64 KB default). For large transfers, open a P2P DataChannel.

  2. Expecting peer.send latency to match. Server-routed adds a hop. For chat and control messages it's still fast enough; for tight games, switch to DataChannel.

  3. Holding peer.send-equivalent calls before join resolves. In simple-peer, you build the Peer and send when "connect" fires. Here, you await peer.join(channel) first, then send. Or queue your sends behind the joined event.

  4. Forgetting close() is terminal. A simple-peer-style "destroy and replay on close" pattern won't work — peer.close() can't rejoin. Construct a new MeteredPeer if you really need to.

  5. Trusting from fields nested in your data payload. simple-peer didn't have envelope-level identity — every protocol you built had its own from. Now use the SDK's senderPeerId (server-stamped, trustworthy) instead of data.from.

  6. replaceTrack partial failures. simple-peer's replaceTrack is 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 throws MeteredPeerReplaceTrackError carrying per-peer succeeded and failed lists 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;
    }

See also