WebRTC Video Call
Build a multi-party video call with Metered Realtime Messaging + Metered TURN, using MeteredPeer. The SDK handles SDP exchange, ICE trickle, perfect negotiation, peer discovery via presence, and MediaStream fan-out. You write maybe 80 lines.
For comparison, the raw-WebSocket version of this guide is ~250 lines. The SDK replaces all of the SDP-exchange + ICE-routing plumbing with a single peer.join(channel).
What you'll build
Two browser tabs (or two users on different devices) join the same channel. Each gets the other's camera + microphone, rendered into a <video> element. Add more tabs → automatic mesh, every peer sees every other peer.
Prerequisites
- A Metered account with Realtime Messaging enabled.
- An
sk_live_…secret key from Dashboard → Realtime Messaging → Keys (for minting JWTs). - A TURN credential pair from Dashboard → TURN → Credentials. We'll embed it in the JWT.
- A backend that can sign HS256 JWTs (any framework).
pk_live_ keys work for this guide too — see WebRTC No Backend for a backend-less variant.
Step 1 — Mint a JWT with embedded TURN creds
Your backend signs an HS256 JWT carrying the user's identity + channel scope + TURN credentials.
const jwt = require("jsonwebtoken");
const KEY_ID = process.env.SIGNALLING_KEY_ID; // sk_id_…
const SECRET = process.env.SIGNALLING_KEY_SECRET; // sk_secret_…
app.get("/api/mint-call-token", requireAuth, async (req, res) => {
const turnCreds = await fetchTurnCredentialsForUser(req.user.id);
// turnCreds shape: [
// { urls: "stun:stun.relay.metered.ca:80" },
// { urls: "turn:standard.relay.metered.ca:80", username: "...", credential: "..." },
// { urls: "turns:standard.relay.metered.ca:443", username: "...", credential: "..." },
// ]
const token = jwt.sign(
{
sub: req.user.id,
channels: [`app_${req.appId}/call-*`],
permissions: ["publish", "subscribe", "presence", "send"],
metadata: {
iceServers: turnCreds,
},
peerMetadata: {
username: req.user.name,
avatarUrl: req.user.avatar,
},
exp: Math.floor(Date.now() / 1000) + 3600,
},
SECRET,
{ algorithm: "HS256", header: { alg: "HS256", kid: KEY_ID } },
);
res.json({ token });
});
If you'd rather not host the JWT-signing yourself, call the REST API POST /v1/tokens instead.
Step 2 — Browser code
<!DOCTYPE html>
<html>
<head><title>Metered Video Call</title></head>
<body>
<video id="local" autoplay playsinline muted></video>
<div id="remotes"></div>
<script type="module" src="./call.js"></script>
</body>
</html>
import { MeteredPeer } from "@metered-ca/peer";
const peer = new MeteredPeer({
tokenProvider: async () => {
const r = await fetch("/api/mint-call-token");
if (!r.ok) throw new Error("token mint failed");
const { token } = await r.json();
return token;
},
});
// Get the local camera + mic.
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
document.querySelector("#local").srcObject = localStream;
// Attach to the SDK so every peer (current + future) receives it.
// Metadata labels the stream so receivers can lay it out correctly
// (vs simultaneous screen-share streams added below).
peer.addStream(localStream, { role: "camera", label: "front cam" });
// Wire UI per remote peer.
const tiles = new Map(); // peerId → <video> element
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream, metadata }) => {
const tile = document.createElement("video");
tile.autoplay = true;
tile.playsInline = true;
tile.dataset.peerId = remote.id;
tile.dataset.streamId = stream.id;
if (metadata?.role === "screen") tile.classList.add("screen-share");
document.querySelector("#remotes").appendChild(tile);
tile.srcObject = stream;
tiles.set(`${remote.id}:${stream.id}`, tile);
});
remote.on("stream-removed", ({ stream }) => {
const tile = tiles.get(`${remote.id}:${stream.id}`);
tile?.remove();
tiles.delete(`${remote.id}:${stream.id}`);
});
remote.on("state-change", ({ to }) => {
for (const [key, tile] of tiles) {
if (key.startsWith(`${remote.id}:`)) {
tile.style.opacity = to === "connected" ? 1 : 0.3;
}
}
});
});
peer.on("peer-left", ({ peer: remote }) => {
for (const [key, tile] of tiles) {
if (key.startsWith(`${remote.id}:`)) {
tile.remove();
tiles.delete(key);
}
}
});
// Optional: reconnect banner.
peer.on("state-change", ({ to }) => {
document.body.dataset.state = to;
});
// Join the call.
await peer.join("app_xyz/call-room-42");
That's it. Two browser tabs open this page → mutual video call. Three tabs → three-way mesh. Each peer connects directly to each other peer; TURN relays only when direct connect fails.
Step 3 — Add chat / control messages
Same channel, different message type. peer.send is the broadcast.
peer.on("data", ({ senderPeerId, data }) => {
if (data.type === "chat") {
appendChatMessage(senderPeerId, data.text);
}
});
document.querySelector("#send-chat").onclick = async () => {
const text = document.querySelector("#chat-input").value;
await peer.send({ type: "chat", text });
};
peer.send is server-routed, not P2P. For chat, that's ideal — it works before ICE completes, doesn't depend on the peer connection succeeding, and you don't manage DataChannels. See routing trade-offs on the MeteredPeer reference.
Step 4 — Mute / camera off / screen share
Toggle audio
function setAudioEnabled(enabled) {
localStream.getAudioTracks().forEach((t) => (t.enabled = enabled));
peer.send({ type: "audio-state", enabled }); // tell other peers
}
The track.enabled = false keeps the SDP sender wired up — peers still see the track but it's silent. Cheaper than removing and re-adding.
Switch camera ↔ screen share — two patterns
Pattern A: simultaneous camera + screen share (preferred for modern UIs)
Add the screen as a SECOND stream alongside the camera. Receivers see both and lay them out side-by-side, picture-in-picture, or however your UI decides:
let screenStream = null;
async function startScreenShare() {
screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
peer.addStream(screenStream, { role: "screen", label: "shared screen" });
// User clicks "stop sharing" in the browser UI
screenStream.getVideoTracks()[0].onended = () => stopScreenShare();
}
function stopScreenShare() {
if (!screenStream) return;
peer.removeStream(screenStream);
screenStream.getTracks().forEach((t) => t.stop());
screenStream = null;
}
Receivers branch on metadata.role:
remote.on("stream-added", ({ stream, metadata }) => {
if (metadata?.role === "camera") attachToFaceTile(stream);
if (metadata?.role === "screen") attachToScreenTile(stream);
});
Pattern B: in-place swap (replaces camera with screen)
If your UI only ever shows one video tile per peer, swap the camera track for the screen track without renegotiating:
async function switchToScreen() {
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
const oldVideoTrack = localStream.getVideoTracks()[0];
const newVideoTrack = screen.getVideoTracks()[0];
try {
await peer.replaceTrack(oldVideoTrack, newVideoTrack);
} catch (e) {
if (e instanceof MeteredPeerReplaceTrackError) {
for (const { peerId, err } of e.failed) {
console.warn(`screen share failed for ${peerId}:`, err);
}
} else throw e;
}
oldVideoTrack.stop();
newVideoTrack.onended = () => switchToCamera(); // user clicked "stop sharing"
}
replaceTrack swaps the sender without renegotiation — much faster than removeStream + addStream. Even partial failure leaves the call working — e.failed lists peers that didn't get the swap so you can retry only those.
Which to use? Most modern calling UIs use Pattern A — Zoom, Meet, Teams all show camera + screen as separate panels. Pattern B is better when the UI is strictly one-tile-per-peer (mobile, older designs) or when you want to save bandwidth (one fewer encoder running).
Pitfalls
Not handling
peer-left. If a peer leaves and your code doesn't remove their<video>element, it sits there frozen. Always clean up inpeer-left.Attaching
localStreamafterjoin()without expecting renegotiation. Adding a stream after join triggers an SDP renegotiation cycle (~200–600 ms) per peer. For best call setup latency, getgetUserMediafirst, thenaddStream, THENjoin. The example in Step 2 does it that way.Reading
data.frominstead ofsenderPeerId.senderPeerIdis server-stamped;data.fromis whatever the sender put. A spoofed sender bypasses your access checks if you trustdata.from.Not testing reconnect. Real users WILL lose Wi-Fi mid-call. Read Reconnect Best Practices and verify your "reconnecting" UI before you ship.
Letting
getUserMediafail silently. If the user denies camera access, youraddStreamnever happens and peers see no video from you. Wrap thegetUserMediacall in try/catch and show an "allow camera" UI.Forgetting to stop
localStreamon close.peer.close()doesn't stop the underlying tracks — the camera light stays on. CalllocalStream.getTracks().forEach(t => t.stop())in your cleanup.Not embedding TURN creds. Calls between users on restrictive networks (cellular, corporate firewalls) need TURN. If you skip
iceServers, ~30% of calls in production will fail to connect. Always embed creds in the JWT.
See also
- Raw WebSocket version of this guide — same outcome, ~3× the code
- WebRTC No Backend — same call, but with a
pk_live_key + client-side TURN fetch - Reconnect Best Practices — required production reading
MeteredPeerreference — every method + eventRemotePeerreference — thetrackevent, thepcescape hatch- Authentication — full JWT walkthrough