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.
- A secret key (
sk_id_…+sk_secret_…) from Dashboard → Realtime Messaging → API Keys (for minting JWTs). Leave "Auto-inject TURN" enabled (default). - An active TURN service on your app (any tier, including free). The Realtime Messaging service auto-fetches your TURN credentials and injects them into the welcome message — you don't need to embed them yourself.
- 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
Your backend signs an HS256 JWT carrying the user's identity + channel scope. No TURN credentials needed — the Realtime Messaging service auto-injects them as long as your sk_ key has "Auto-inject TURN" enabled in the dashboard (default).
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 token = jwt.sign(
{
sub: req.user.id,
channels: [`app_${req.appId}/call-*`],
permissions: ["publish", "subscribe", "presence", "send"],
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.
If you want to use your own TURN service (or per-user TURN credentials), add metadata: { iceServers: [...] } to the JWT payload. Your value wins over auto-injection — see Authentication → TURN credentials.
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/realtime";
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