To migrate from PeerJS to @metered-ca/realtime, you replace PeerJS's point-to-point model with a channel model. Where PeerJS gives you new Peer(id) plus per-target peer.call(remoteId, stream) loops and a hand-maintained peer registry.
The channel model has every participant join a named channel, presence events announce who's there, and one peer.addStream() fan your media out to the whole room. In the process you delete three things you used to own β your TURN configuration, your reconnect loop, and your peer-registry bookkeeping.
This guide gives you the verified API mapping, a full runnable before-and-after port, and the exact lines to remove.
That's the summary. The rest of this page is the migration done in public β a working PeerJS multi-peer app, the same app rewritten on @metered-ca/realtime 1.1.0, both runnable as written, and the two production walls (TURN and reconnection) that send most people here in the first place.
TL;DR: Migrating from PeerJS to
@metered-ca/realtimemeans swapping point-to-pointpeer.call(remoteId)loops for a channel both sidesjoin(), wherepeer.addStream()fans media to everyone and presence events replace your peer registry. You delete your manualpeer.reconnect()logic (reconnection becomes automatic and three-layered) and your TURN setup (Open Relay credentials ship in the box).
Before You Start: What Actually Changes
PeerJS and @metered-ca/realtime solve the same problem β peer-to-peer WebRTC in the browser without a wire-protocol PhD β but they hold the problem differently.
PeerJS is point-to-point. You construct a Peer with an ID, you learn other peers' IDs through some channel of your own, and you dial them one at a time with peer.call(remoteId, stream). A four-person call is four browsers each dialing the other three, and a registry β usually a Map of remoteId β call β that you grow and prune by hand.
@metered-ca/realtime is channel-based. A MeteredPeer joins a named channel. Presence events (peer-joined, peer-left) tell you who is in the room, and a single peer.addStream() fans your media out to every member. There is no dialing and no registry, because the channel is the registry.
Most of the code you delete in this migration is the code that managed that difference by hand. The rest of this guide is mechanical once that shift lands.
One thing that does not change: both libraries are MIT-licensed (npm, 2026-06-15), so there is no license friction in the move.
The Verified PeerJS β @metered-ca/realtime API Mapping
Here is the concept-by-concept mapping, taken from the official PeerJS migration guide and the SDK reference (metered.ca docs, 2026-06-15). Every right-hand cell is a real method or event on the library
| PeerJS concept | PeerJS code |
@metered-ca/realtime equivalent |
What changed |
|---|---|---|---|
| Peer identity | new Peer("alice") |
JWT with sub: "alice", minted server-side |
Stable IDs come from auth, not the constructor. A publishable key (pk_live_β¦) gives a random UUID per connection for prototypes. |
| Initiate a call | peer.call(remoteId, stream) |
peer.addStream(stream) then await peer.join(channel)
|
Fans out to every channel peer; there is no per-target call and no remote ID to dial. |
| Answer a call | peer.on("call", c => c.answer(stream)) |
peer.on("peer-joined", ({ peer: remote }) => β¦) |
No explicit answer step; both sides attach streams and the SDK negotiates. |
| Receive remote media | call.on("stream", s => β¦) |
remote.on("stream-added", ({ stream }) => β¦) |
Per-stream event; re-fires after a reconnect with a fresh MediaStream (same stream.id). |
| Data connection | peer.connect(remoteId) |
peer.sendTo(remoteId, data) (routed) or remote.pc.createDataChannel(...) (real P2P DC) |
Server-routed messaging is one call; a true RTCDataChannel is available via the remote.pc escape hatch. |
| Receive data | conn.on("data", d => β¦) |
peer.on("data", ({ senderPeerId, data }) => β¦) |
senderPeerId is server-stamped β you can trust it. |
| Who's online | You distribute peer IDs yourself | peer.on("peer-joined" / "peer-left", β¦) |
Presence is built in; the roster is an event handler, not a subsystem you design. |
| Reconnect |
peer.reconnect() (manual, single-shot) |
(automatic) | Three-layer reconnection is on by default β delete your reconnect code. |
| Disconnect | peer.disconnect() |
(automatic) | Lifecycle is managed; peer.close() is terminal teardown only. |
| Swap a track | (not supported β tear down) | peer.replaceTrack(oldTrack, newTrack) |
Swap a camera mid-call with no renegotiation. |
| Signalling server | PeerServer (hosted or self-hosted) | Managed endpoint wss://rms.metered.ca/v1
|
Managed-only β no PeerServer to deploy, and no self-host option. |
| TURN | BYO (config iceServers yourself) |
Delivered automatically via Open Relay / JWT metadata | TURN credentials arrive in the box and refresh on every reconnect. |
Sources: the official PeerJS β @metered-ca/realtime migration guide and the SDK reference (metered.ca docs, 2026-06-15); PeerJS surface from the PeerJS API docs, 2026-06-15.
Two rows deserve a flag before you start, because they trip people up.
peer.close() is terminal in @metered-ca/realtime. Where PeerJS let you disconnect() and come back on the same object, here you construct a fresh MeteredPeer to rejoin. Reach for close() only when you mean "tear this down for good."
And peer.sendTo() rejects when the target peer is offline (peer_not_found), where PeerJS quietly queued data for an absent peer. If you relied on that queueing, add presence-awareness β check that the peer is in the room before you send.
The Centerpiece: A Full Runnable Before/After Port
Here is the migration you actually came for: a small-group video call β the most common PeerJS app β ported end to end. The @metered-ca/realtime version runs as written: open it in two or more tabs and the peers discover each other and exchange media through the channel.
The PeerJS version is the realistic starting shape you migrate from β its broker connection and local preview run as-is, but its peer discovery is a deliberately minimal stand-in (more on that below), because real PeerJS apps wire that part themselves. That gap is exactly the point.
This is the case from the API mapping made concrete: PeerJS's peer.call() loop and its peer registry on the left, channel fan-out on the right.
Before: The PeerJS Multi-Peer App
This is a representative small-group PeerJS app. A peer announces itself, learns about others through a tiny roster channel, and dials each one with peer.call(). The connections object is the hand-maintained registry.
Save it as peerjs-app.html; opened in two tabs, each connects to the PeerJS cloud broker and shows its own local preview.
One honest caveat about this file, since this guide runs its own code: the peer.connect(ROOM) roster trick is a placeholder, not a working rendezvous β it dials a peer literally named "demo-room" that nothing registers, so it resolves to peer-unavailable and the peer.call() dial never actually fires. That is on purpose.
A real PeerJS app supplies this discovery layer itself β a signalling server, a presence service, a database β and that whole missing layer is precisely what the right-hand rewrite gets for free. We left the stand-in honest rather than smuggle in a hidden rendezvous server the migration story isn't about.
<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>PeerJS multi-peer call</title></head>
<body>
<div id="status">startingβ¦</div>
<div id="videos"></div>
<script src="https://unpkg.com/peerjs@1.5.5/dist/peerjs.min.js"></script>
<script>
const statusEl = document.getElementById("status");
const videosEl = document.getElementById("videos");
const setStatus = (t) => (statusEl.textContent = t);
// A unique-ish id for this tab, and a shared "room" we all dial through.
const myId = "peer-" + Math.random().toString(36).slice(2, 8);
const ROOM = "demo-room";
// THE REGISTRY: every remote call we are responsible for, by peer id.
const connections = {};
function attachVideo(id, stream) {
let v = document.getElementById("v-" + id);
if (!v) {
v = document.createElement("video");
v.id = "v-" + id;
v.autoplay = true;
v.playsInline = true;
v.width = 320;
videosEl.appendChild(v);
}
v.srcObject = stream;
}
function removeVideo(id) {
const v = document.getElementById("v-" + id);
if (v) v.remove();
}
async function main() {
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
attachVideo(myId, localStream);
// The PeerJS broker assigns/keeps our id.
const peer = new Peer(myId);
// Inbound calls: answer with our stream, then show theirs.
peer.on("call", (call) => {
call.answer(localStream);
connections[call.peer] = call;
call.on("stream", (remoteStream) => attachVideo(call.peer, remoteStream));
call.on("close", () => {
removeVideo(call.peer);
delete connections[call.peer];
});
});
peer.on("open", () => {
setStatus("connected as " + myId + " β open another tab");
// Discovery is YOUR job in PeerJS. We use a second PeerJS data
// connection to a fixed "roster" peer to learn who else is here.
// (In a real app this is a signalling server, a DB, or a presence service.)
const roster = peer.connect(ROOM);
roster.on("open", () => roster.send({ join: myId }));
roster.on("data", (msg) => {
// Someone told us about a peer we don't have yet β dial them.
if (msg.peerId && msg.peerId !== myId && !connections[msg.peerId]) {
const call = peer.call(msg.peerId, localStream);
connections[msg.peerId] = call;
call.on("stream", (s) => attachVideo(msg.peerId, s));
call.on("close", () => {
removeVideo(msg.peerId);
delete connections[msg.peerId];
});
}
});
});
// Manual, single-shot reconnect: PeerJS does NOT retry for you.
peer.on("disconnected", () => {
setStatus("disconnected β attempting reconnectβ¦");
peer.reconnect();
});
peer.on("error", (err) => setStatus("error: " + err.type));
}
main();
</script>
</body>
</html>
Look at how much of this file is plumbing rather than your app. The connections registry. The peer.call() dial in two places. The disconnected β reconnect() handler. The roster connection that exists only because PeerJS doesn't tell you who's in the room. None of it is your product β all of it is yours to maintain.
A note on the PeerJS discovery shortcut above. PeerJS is explicit that distributing peer IDs is your responsibility β "You're in charge of communicating the peer IDs between users of your site" (PeerJS getting-started guide, 2026-06-15). The roster trick is a minimal stand-in for the signalling/presence layer a production PeerJS app builds. That whole layer is what disappears on the right.
After: The Same App on @metered-ca/realtime 1.1.0
Now the rewrite. Same behavior β a multi-peer call you join from several tabs β with the registry, the dial loop, the discovery channel, and the reconnect handler all gone. Save as metered-app.html and open in two tabs.
<!DOCTYPE html>
<html>
<head><meta charset="utf-8" /><title>@metered-ca/realtime multi-peer call</title></head>
<body>
<div id="status">startingβ¦</div>
<div id="videos"></div>
<script type="module">
import { MeteredPeer } from "https://esm.sh/@metered-ca/realtime@1.1.0";
const statusEl = document.getElementById("status");
const videosEl = document.getElementById("videos");
const setStatus = (t) => (statusEl.textContent = t);
function attachVideo(id, stream) {
let v = document.getElementById("v-" + id);
if (!v) {
v = document.createElement("video");
v.id = "v-" + id;
v.autoplay = true;
v.playsInline = true;
v.width = 320;
videosEl.appendChild(v);
}
v.srcObject = stream;
}
function removeVideo(id) {
const v = document.getElementById("v-" + id);
if (v) v.remove();
}
async function main() {
// 1) Get the camera FIRST, then attach handlers, then join.
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
// 2) Publishable key for a prototype. For production, swap this for a
// tokenProvider that returns a server-minted JWT (see below).
// NOTE: the pk_ key MUST have the "Send" permission enabled for WebRTC.
const peer = new MeteredPeer({ apiKey: "pk_live_REPLACE_ME" });
// 3) Presence replaces the registry: the channel tells us who's here.
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream }) => attachVideo(remote.id, stream));
});
peer.on("peer-left", ({ peer: remote }) => removeVideo(remote.id));
// 4) Surface reconnection state to the UI β but write NONE of the retry logic.
peer.on("state-change", ({ to }) => setStatus("peer: " + to));
peer.on("error", ({ err }) => setStatus("error: " + err.name));
// 5) Show our own preview.
attachVideo("me", localStream);
// 6) Attach the stream BEFORE joining, then join the channel ONCE.
peer.addStream(localStream, { role: "camera" });
await peer.join("room-42");
setStatus("joined room-42 β open another tab");
}
main();
</script>
</body>
</html>
Count what left. No connections registry. No peer.call() β addStream() fans out to whoever is in room-42. No roster connection β peer-joined tells you who's there. No disconnected/reconnect() handler β the SDK retries on its own, and you only display the state. The file is mostly your app now.
Two ordering rules make the rewrite work, both load-bearing:
-
getUserMediafirst, then attach handlers, thenjoin. Wiringpeer-joinedandstream-addedbeforejoin()guarantees you don't miss the first peer's media. -
addStream()beforejoin(). The local stream must be attached before you enter the channel, or the first negotiation has nothing to send.
We ran this rewrite to check it: three Chrome tabs auto-joined room-42 through wss://rms.metered.ca/v1, each reached state-change idleβjoiningβjoined, and every tab's peer-joined fired for the others with the correct remote.id β presence and the channel model work exactly as written. Whether remote video then appears hinges on one key setting: the pk_live_ key must have the Send permission, which is the connectivity gotcha in the next section.
Going to Production: Swap the Key for a tokenProvider
The publishable key above is right for a prototype β random UUID per connection, no backend. For production you want stable per-user identity, scoped channel permissions, and TURN credentials delivered inside the token. That is one constructor change plus a small backend endpoint.
import { MeteredPeer } from "@metered-ca/realtime";
const peer = new MeteredPeer({
// Called on first connect AND on every reconnect β return a FRESH JWT each time.
tokenProvider: async () => {
const res = await fetch("/api/realtime-token");
const { token } = await res.json();
return token;
},
});
Your backend mints an HS256 JWT signed with your secret key. The sub claim becomes the peer's stable ID (this is the PeerJS new Peer("alice") equivalent), channels patterns scope what the token may touch, and metadata.iceServers carries TURN credentials the SDK reads automatically.
The SDK calls your tokenProvider again on every reconnect, so credentials refresh themselves β return a fresh token each call (SDK reference, 2026-06-15).
That auth model is something PeerJS has no equivalent for, and it is also where TURN stops being your problem β which is the first of the two walls.
Gotcha #1: The TURN / Connectivity Wall
Here is the failure that brings most people to this guide. Your PeerJS call works perfectly on your machine, works between two laptops on your home Wi-Fi, and ships. Then a teammate joins from an office network, or a user joins from their phone on cellular, and the call connects to "connectingβ¦" and stays there. Nothing in your console explains it.
This is the TURN wall, and it is silent by design. Two peers behind symmetric NATs β common on corporate networks and many mobile carriers β cannot form a direct connection; they need a TURN relay to forward media between them. PeerJS's own documentation is candid that you must provide one: "you need to provide your own TURN server" for peers that can't connect directly (PeerJS docs, 2026-06-15).
The default config gestures at community relays, but they publish no capacity, no quota, and no SLA. They also listen on plain port 3478 with no TLS-on-443 variant β exactly the traffic strict firewalls drop.
So "peerjs not working in production" almost always decodes to "peerjs turn server missing." Your historical options were all unpleasant: run coturn yourself (ports 80/443, TLS certs, bandwidth bills, monitoring), pay a commercial relay by the gigabyte, or use something like openrelayproject.org
What the Migration Does About It
@metered-ca/realtime ships TURN in the box through Open Relay. The relays listen on ports 80 and 443 with TLS β the configuration that punches through the corporate firewalls plain relay traffic can't (Open Relay docs, 2026-06-15).
In the publishable-key prototype above, you get working relay coverage with no iceServers config at all. In production, TURN credentials ride inside the JWT your tokenProvider returns and refresh on every reconnect, so RTCPeerConnection always has live credentials without you babysitting expiring secrets.
What you delete: any iceServers array you hand-built for PeerJS, any coturn deployment that existed only to back it, and any credential-rotation cron you wrote to keep TURN secrets fresh.
The one connectivity gotcha that bites during the port. For a publishable key (
pk_live_β¦) to do WebRTC, the key needs theSendpermission enabled. When you create apk_key,Subscribe/Publish/Presenceare on by default butSendis off, becauseSendlets any holder of the browser-side key direct-message any peer. WebRTC uses thesendoperation under the hood to exchange SDP and ICE β so without it,join()andaddStream()succeed and presence fires, butpeer-joined β stream-addednever happens and no video appears (SDK reference, 2026-06-15). If your ported app connects but shows no remote video, check this first. It is the new equivalent of the PeerJS TURN wall: everything looks fine until the media won't flow.
If your app needs to traverse real-world NATs β and any app with users outside your office does β this single change is usually the difference between "works in the demo" and "works from a hospital guest network." For the deeper background on relays, see the Open Relay project.
Gotcha #2: Reconnection β Delete Your Retry Code
The second wall is quieter but just as real: networks blink, laptops sleep, and phones hop from Wi-Fi to cellular mid-sentence. What your app does in those few seconds is the whole difference between a professional call and a frustrating one.
PeerJS hands reconnection to you. Its answer is peer.reconnect() β a method you call manually, one attempt per call, with no backoff, no schedule, and no retry cap behind it (PeerJS docs, 2026-06-15). Until your code notices the drop and intervenes, the peer sits idle. Doing it well means writing the retry loop, the jitter, the give-up logic, and the teardown-and-rebuild dance when a connection can't be saved. In the PeerJS app above, that's the peer.on("disconnected", () => peer.reconnect()) handler β and in a real app it is considerably more than one line.
@metered-ca/realtime reconnects in three layers, and you write none of them (SDK reference, 2026-06-15):
- Signalling WebSocket β exponential backoff from 500 ms toward a 30-second ceiling, jittered, close-code-aware, capped at 100 attempts by default. A broken auth path stops with a definite error instead of hammering forever.
-
ICE-restart ladder β per peer, up to 9 attempts over roughly two minutes, surfaced to your UI as
remote.state === "reconnecting". This is the layer that saves a call when a phone roams networks and every address changes at once. -
Channel reconciliation β when the socket returns, your channels re-subscribe and a fresh
RTCPeerConnection(with fresh TURN credentials) is swapped inside the sameRemotePeerobject. The peer references your UI state holds stay valid across the drop.
That third layer is the one that changes how you write your UI. Because the RemotePeer object survives, you don't tear down and rebuild your video tiles on every blip β no flicker, no peer-list reset.
Exactly What to Delete
When you port your reconnection handling, remove all of this:
- The
peer.on("disconnected", β¦)handler that calledpeer.reconnect(). - Any retry loop, backoff timer, or attempt counter you built around it.
- Any "connection failed β rebuild the call from scratch" teardown logic.
- Any code that re-creates video elements or re-populates a peer list after a reconnect.
Replace the lot with a single display handler if you want to show status:
peer.on("state-change", ({ to }) => {
// to: "idle" | "joining" | "joined" | "reconnecting" | "leaving" | "closed"
showConnectionBadge(to);
});
Two things to keep in mind so you don't fight the SDK. Read to off the state-change payload β the event is the transition { from, to }, not { state }. And never cache remote.pc or a MediaStream object across a reconnect: both are replaced (the remote.id and stream.id stay stable), so re-bind your <video>.srcObject from each stream-added event, which re-fires after a reconcile (SDK reference, 2026-06-15).
For a deeper, runnable walk-through that kills the network mid-call and watches it heal, see our WebRTC reconnect tutorial.
Migrating Data Connections (peer.connect)
If your PeerJS app uses data connections rather than (or alongside) media, the port has two shapes depending on what you actually need.
For routed messaging β chat, signals, control events β peer.connect(remoteId) plus conn.send() becomes peer.sendTo(remoteId, data) for a directed message or peer.send(data) to broadcast to the channel. On the receiving side, conn.on("data", β¦) becomes peer.on("data", ({ senderPeerId, data }) => β¦), and senderPeerId is server-stamped so you can trust who sent it. Remember the offline difference from earlier: sendTo rejects a missing peer instead of queueing.
For a real peer-to-peer RTCDataChannel β game ticks, telemetry, file transfer β open one through the remote.pc escape hatch with remote.pc.createDataChannel(...). Wire its creation to the connection state so it survives reconnects:
remote.on("state-change", ({ to }) => {
if (to === "connected") {
const dc = remote.pc.createDataChannel("game");
dc.onmessage = (e) => handleTick(e.data);
}
});
This fires on the initial connect and on each reconcile cycle, so the data channel reopens after a drop (SDK reference, 2026-06-15). One honest gap to plan for: PeerJS's BinaryPack transparently chunks large data-channel sends at 16 KB, and @metered-ca/realtime does not auto-chunk. If you fire multi-hundred-kilobyte blobs in a single send, you'll split them yourself.
A Migration Checklist
Work top to bottom:
-
Install the package β
npm install @metered-ca/realtime(1.1.0, MIT, zero runtime dependencies; npm, 2026-06-15). -
Pick an auth mode β
pk_live_publishable key for prototypes (enable theSendpermission for WebRTC), or atokenProviderminting JWTs for production. -
Replace
new Peer(id)β identity now comes from the JWTsubclaim (or a random UUID under apk_key). -
Replace the call loop β
peer.call(remoteId, stream)per target becomes onepeer.addStream(stream)plusawait peer.join(channel). -
Replace discovery β delete your roster/peer-ID distribution; use
peer-joined/peer-left. -
Delete the registry β the channel membership replaces your hand-maintained
Mapof connections. -
Delete reconnection code β remove
peer.reconnect()and everything around it; keep onestate-changedisplay handler if you want a status badge. -
Delete TURN setup β drop your
iceServersconfig and any coturn you ran for PeerJS. -
Port data connections β
peer.connect()βsendTo/send, or theremote.pc.createDataChannelescape hatch for a real DC. - Re-test on a hostile network β symmetric NAT or a corporate/guest Wi-Fi, not just localhost. This is where the migration actually pays off.
The full, canonical version of this mapping lives in the official migration guide β keep it open as you work.
Frequently Asked Questions
How do I migrate from PeerJS to @metered-ca/realtime?
Replace PeerJS's point-to-point model with a channel model. new Peer(id) becomes a JWT sub claim, per-target peer.call(remoteId, stream) loops become one peer.addStream() plus peer.join(channel), and presence events replace your peer registry. You delete your peer.reconnect() logic (reconnection is automatic) and your TURN setup (Open Relay credentials are delivered automatically). The official migration guide has the full mapping.
Why is my PeerJS app not working in production?
The most common cause is a missing TURN server. PeerJS calls work on local networks but fail silently between peers behind symmetric NATs or corporate firewalls, because those peers need a TURN relay to forward media and PeerJS leaves TURN to you (PeerJS docs, 2026-06-15). The second most common cause is reconnection: PeerJS's peer.reconnect() is manual and single-shot, so dropped calls stay dropped until your code intervenes.
Is migrating from PeerJS worth it?
It is worth it if your app must work on networks you don't control. Migrating removes three things you otherwise maintain forever β TURN configuration, reconnection logic, and peer-registry bookkeeping β and replaces them with built-in TURN delivery, automatic three-layer reconnection, and presence-based discovery.
It is not worth it if you must self-host signalling, since @metered-ca/realtime is managed-service and PeerJS's self-hostable PeerServer is the better fit.
Do I need to replace my PeerJS server (PeerServer)?
You retire it. @metered-ca/realtime connects to a managed signalling endpoint (wss://rms.metered.ca/v1), so there is no PeerServer to deploy, scale, or monitor β that whole service disappears from your stack. The trade is that signalling is managed-only: if running your own PeerServer is a hard requirement (compliance, air-gapping, data residency), stay on PeerJS, which is the only option here that ships a self-hostable broker.
How do I handle PeerJS reconnection after migrating?
You delete it. PeerJS requires manual peer.reconnect() calls; @metered-ca/realtime reconnects automatically across three layers β WebSocket backoff, a 9-attempt ICE-restart ladder, and channel reconciliation that preserves your RemotePeer references across the drop (SDK reference, 2026-06-15). Remove your reconnect handler and retry loop; keep at most a single state-change listener to show a status badge.
The Bottom Line
Migrating from PeerJS to @metered-ca/realtime is, at heart, a deletion. You remove the peer registry, the peer.call() loop, the discovery channel, the manual peer.reconnect() handler, and the TURN configuration β and you replace them with a channel you join, presence events that announce the room, an addStream() that fans out to everyone, automatic three-layer reconnection, and TURN delivered in the box.
Do it when your app has to work on real networks β the symmetric NAT, the corporate firewall, the phone that roams mid-call.
Ready to start? Run npm install @metered-ca/realtime and open the npm package page. Keep the official migration guide open beside it β it has the canonical mapping, side-by-side code, and the full porting checklist.
Related reading: PeerJS alternatives in 2026 Β· WebRTC reconnect: auto-heal a call Β· the Open Relay free TURN project














