WebRTC Video Call Tutorial: Build a 1:1 Video Chat in JavaScript with @metered-ca/peer
Scope — a 2-tab, 1:1 WebRTC video call. Two browser tabs join the same channel, each publishes its camera, and each renders the other's stream. No signaling server to run, no
peer.call(remoteId)loops, no hand-wired TURN. We use a publishable key (pk_live_…) so there's nothing server-side to stand up. For group calls, React, or React Native, see Next steps — those are out of scope here on purpose.
Goal
This tutorial builds a complete WebRTC video call in JavaScript — open two browser tabs, click Join, and each tab shows the other's live camera — using the MIT-licensed @metered-ca/peer SDK, with no signaling server to run, TURN credentials delivered for you, and automatic reconnection if the network drops. It's a single HTML file you can copy-paste and run.
Most WebRTC video call tutorials make you
(a) stand up and operate a Node/Socket.IO signaling server,
(b) wrestle TURN configuration yourself, and
(c) leave you with nothing when the connection drops.
As of 2026-06-01, even the popular "WebRTC without the boilerplate" peer libraries advertise a free signaling cloud but don't mention TURN at all (verified against the leading incumbent's homepage, 2026-06-01) — so TURN and reconnection are still your problem. This tutorial closes that exact trio of gaps.
TL;DR. A WebRTC video call needs four pieces:
getUserMediato capture the camera, a signaling channel to exchange SDP offers/answers and ICE candidates, anRTCPeerConnectionwith STUN and TURN for NAT traversal, and a handler to display the remote stream. Raw WebRTC leaves signaling, TURN, and reconnection to you.@metered-ca/peerhandles all three, so the call below is a few dozen client lines and zero server lines.
Raw WebRTC vs. this tutorial — what you have to build yourself
| Piece a 1:1 video call needs | Hand-rolled WebRTC | This tutorial (@metered-ca/peer) |
|---|---|---|
Capture camera/mic (getUserMedia) |
You write it | You write it (it's your media) |
| Signaling server (offer/answer/ICE relay) | You build + host + scale a Node/WS server (~150–250 LOC) | Managed endpoint — nothing to run |
| Peer connection + add tracks | You wire RTCPeerConnection by hand |
peer.addStream() |
| TURN for real-world NAT | BYO — self-host coturn or pay a provider | Credentials delivered to the client; free Open Relay tier |
| Display remote stream |
pc.ontrack → event.streams[0]
|
remote.on("stream-added", …) |
| Reconnect after a network drop | Not built in — you detect ICE failure + ICE-restart by hand | Automatic 3-layer reconnect |
Prerequisites
- Node 18+ and npm — only to install the SDK and run a tiny static server. (Notably: no backend, no signaling server to write or run.)
- A free publishable key (
pk_live_…) from your Metered dashboard — sign up at metered.ca. It goes straight in the browser; no token server needed for this prototype path. - A modern browser: Chrome 90+ / Firefox 90+ / Safari 15+.
- One thing to internalize up front: getUserMedia only works over HTTPS or on localhost. Serve the file — don't open it as a file:// path. More on this in Common pitfalls.
The minimal runnable code
One file. Save it as index.html, drop in your pk_live_ key, serve it (see Run it), and open it in two tabs.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>WebRTC video call — @metered-ca/peer</title>
<style>
video { width: 320px; background: #111; border-radius: 8px; margin: 4px; }
body { font-family: system-ui, sans-serif; padding: 16px; }
#status { color: #6d5efc; font-weight: 600; }
</style>
</head>
<body>
<h1>2-tab WebRTC video call</h1>
<button id="join">Join call</button>
<p id="status">idle</p>
<div>
<video id="local" autoplay playsinline muted></video>
<video id="remote" autoplay playsinline></video>
</div>
<script type="module">
import { MeteredPeer } from "https://esm.sh/@metered-ca/peer@1.0.6";
const CHANNEL = "room-42"; // both tabs MUST join the SAME channel
const PK = "pk_live_REPLACE_ME"; // <-- your publishable key
const localVideo = document.getElementById("local");
const remoteVideo = document.getElementById("remote");
const statusEl = document.getElementById("status");
document.getElementById("join").onclick = async () => {
// 1. Capture camera + mic (must be HTTPS or localhost)
const localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
localVideo.srcObject = localStream;
// 2. One peer, one channel — publishable-key auth, no token server
const peer = new MeteredPeer({ apiKey: PK });
// 3. When another peer joins, listen for THEIR media on the remote peer
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream }) => {
remoteVideo.srcObject = stream;
});
// 4. Show the call recovering on its own after a network blip
remote.on("state-change", ({ to }) => {
statusEl.textContent = to; // e.g. "reconnecting" → "connected"
});
});
// 5. Publish our camera to everyone in the channel (no per-target call)
peer.addStream(localStream, { role: "camera" });
// 6. Join — this is what actually connects
await peer.join(CHANNEL);
statusEl.textContent = "joined " + CHANNEL;
};
</script>
</body>
</html>
Both tabs run the identical code. There is no "caller" and no "callee" — joining the same channel is the whole handshake.
Step-by-step
Walk the code top to bottom — one idea per step.
1 — getUserMedia first. We grab the local camera and microphone with navigator.mediaDevices.getUserMedia({ video: true, audio: true }) before touching the SDK. The returned MediaStream is what we'll both render locally and hand to the SDK to publish. muted on the local <video> stops you hearing your own echo; playsinline keeps mobile Safari from forcing fullscreen. This call is the one piece you always write yourself — it's your media, captured in your page. (It's also the most-searched API in WebRTC; everything else is plumbing this tutorial removes.)
2 — One MeteredPeer, one channel. new MeteredPeer({ apiKey: PK }) creates the peer with publishable-key auth — the simplest mode, no token server. Mentally: a MeteredPeer is bound to a single channel. That's the core shift from older peer-ID libraries where you'd dial individual IDs. Here you join a room and the SDK discovers peers for you.
3 — React to peers, not to "calls". Instead of an explicit answer step, you listen for peer-joined on the top-level peer. Each remote peer is its own object that emits its own media events, so we attach stream-added on the remote peer (not on the top-level peer) and point the remote <video> at the incoming stream. The mental model: top-level events = who's here (peer-joined, peer-left); per-peer events = what they're sending (stream-added, track, state-change). (If you prefer the lower-level shape, the remote peer also emits track with ({ streams }) => streams[0] — stream-added is the higher-level convenience.)
4 — A built-in reconnect signal. We also listen for state-change on the remote peer purely to show what's happening. When the network blips, the SDK drives the peer through reconnecting and back to connected on its own — you don't write any of that recovery logic. We'll demo this for real in Keep the call alive.
5 — addStream() fans out. peer.addStream(localStream, { role: "camera" }) publishes your camera to every peer in the channel — no per-target loop, no peer.call(remoteId). The optional metadata ({ role: "camera" }) rides along so the other side can label the tile (camera vs. screen-share, etc.). Call it before join() so the stream is ready the instant negotiation starts.
6 — join() is the connect. Nothing is on the wire until await peer.join(CHANNEL). After it resolves, the SDK runs SDP/ICE exchange, perfect negotiation, TURN delivery, and reconnection for you. When the second tab joins, the first tab's peer-joined fires, streams attach both directions, and you have a call.
How signaling works (and why you don't run a server)
Signaling is the step where two peers discover each other and negotiate how to connect — they exchange SDP offers and answers plus ICE candidates through a mutually agreed server. WebRTC deliberately does not define the signaling transport, which is why every hand-rolled tutorial makes you build one (typically Socket.IO or raw WebSocket over a Node server, ~150–250 lines). Crucially, signaling only sets up the connection — once it's done, media flows directly peer-to-peer.
So the question isn't "do I need signaling?" (you do, always) — it's "do I have to operate the signaling server?" With @metered-ca/peer the answer is no: join() connects to Metered's managed endpoint (wss://rms.metered.ca/v1) and the SDK does the offer/answer/ICE dance for you. You keep all your call code; you skip the infrastructure. The deliberate trade is that the signaling backend is managed-only — there's no self-hosted signaling server option. For prototypes and most real apps, not babysitting a WebSocket server is the feature. (For the canonical concept read, see MDN's "Signaling and video calling".)
Run it
npm install @metered-ca/peer # pulls the package (MIT, zero runtime deps, ~13 KB gzipped w/ WebRTC)
npx serve . # serve the folder over http://localhost:3000
The single-file code above imports the SDK from a CDN so it's true copy-paste. npm install is shown so you can switch to a bundled import { MeteredPeer } from "@metered-ca/peer" in a real build. Any static server works — the one rule is don't open file:// (the camera won't initialize). Then:
- Open http://localhost:3000 in Tab A, click Join call, accept the camera prompt. You'll see your own video on the left.
- Open the same URL in Tab B, click Join call, accept the prompt.
- What you should see: each tab now shows its own camera on the left and the other tab's camera on the right. That's a peer-to-peer WebRTC video call — the media flows directly between the tabs; Metered's signaling only brokered the handshake.
On localhost, the connection almost always succeeds with host/STUN candidates alone — both tabs are on the same machine. The moment you test across real networks (a second device, mobile data, corporate Wi-Fi), you'll need TURN. That's next.
Make it work behind real-world NAT (STUN/TURN)
On a LAN or localhost, peers reach each other directly, so a missing TURN server goes unnoticed. Over the internet, NAT and firewalls block most direct connections — especially symmetric NAT — and the call silently fails with a black tile. STUN helps a peer discover its public address and attempt a direct connection; it's lightweight and free. But when a direct path is impossible, you need TURN, which relays the media through a server. A commonly cited estimate is that roughly a third of real-world connections can't go direct and need a TURN relay (RTC Insights, accessed 2026-06-01). MDN's guidance is blunt: always use STUN/TURN servers you own or are authorized to use (MDN, 2025-09-19).
This is the wall most WebRTC tutorials walk you up to and then leave you at. With @metered-ca/peer, TURN credentials are delivered to the client for you rather than hand-maintained in an iceServers array. On the publishable-key path you're using here, that's enough to start; when you move to the JWT (tokenProvider) path, the SDK reads TURN credentials embedded in the token's metadata.iceServers and the underlying RTCPeerConnection "just works" across NATs.
For the relay itself, Metered's Open Relay Project gives you 20 GB/month of TURN bandwidth free, with zero setup — the exact piece most tutorials make you self-host (running coturn: config, ports 80/443, TLS certs, bandwidth bills) or pay a provider for. You don't have to think about it to follow this tutorial; you just won't hit the NAT wall when you graduate off localhost.
Keep the call alive: automatic reconnection
This is the section every competing tutorial skips — and the one that bites real users first. Raw WebRTC has no built-in reconnection. When the network changes (Wi-Fi to cellular, a tunnel, a flaky hotel connection), you have to detect ICE failure yourself, trigger an ICE restart, and re-run negotiation by hand. Most tutorials never mention it, so the call just dies.
@metered-ca/peer recovers automatically with a three-layer model (per Metered's SDK docs):
- Signaling WebSocket — reconnects with exponential backoff (≈500 ms → 30 s, jittered, close-code aware).
-
Per-peer ICE restart — up to ~9 attempts over ~121 s; surfaces as the remote peer's
statebecoming"reconnecting". -
Channel reconciliation — on WS reconnect, your
RemotePeerobject references are preserved (same===identity, same metadata), while the underlyingRTCPeerConnectionis silently swapped with fresh TURN credentials.
See it for yourself. With the two-tab call running, in Tab B toggle your network (turn Wi-Fi off for a few seconds, or use DevTools → Network → Offline, then back to Online). Watch the #status line we wired in step 4: it goes reconnecting, then connected, and the remote video resumes — you wrote none of that recovery code. Because peer references survive, your UI doesn't flicker or reset its peer list.
The one thing to know if you go lower-level: because the underlying
RTCPeerConnectionis swapped on reconnect, never cache it. If you reach for the low-level connection via the documentedremote.pcescape hatch, a handle you grabbed before a drop points at a dead PC afterward. Re-readremote.pconly after the peer is back toconnected(watch the remote peer'sstate-change). For this tutorial you never touchpc— the SDK re-attaches your streams — but it's the #1 footgun once you go deeper.
Common pitfalls
Each of these is a real "why doesn't it work" — phrased as a quick problem → fix.
You tried
peer.call(remoteId)and there's no such method. This is a channel model, not a peer-ID model. You don't dial peers — youjoin(channel)and letpeer-joinedtell you who showed up, thenaddStream()fans your media to all of them. Both tabs must use the exact same channel string ("room-42"above) or they'll never see each other.Remote tile is a black screen / nothing attaches. Make sure you attach the incoming stream on the remote peer object (
remote.on("stream-added", …)), not on the top-levelpeer, and that you actually setremoteVideo.srcObject = stream. Also publish your camera withaddStream()beforejoin(), so tracks exist when negotiation starts. (In raw WebRTC the equivalent bug is adding tracks after creating the offer, or never wiringontrack.)The camera never turns on /
getUserMediathrows.getUserMediarequires a secure context: HTTPS orlocalhost. Opening the file asfile://…, or hitting a plainhttp://LAN IP, makes it fail or throwNotAllowedError/NotFoundError. Serve overlocalhostin dev and HTTPS in production — and handle the rejected-permission case.It works on
localhostbut fails over the internet. This is the NAT/TURN wall: on a LAN peers connect directly, so a missing TURN relay goes unnoticed; across real networks (symmetric NAT, firewalls) the direct path is blocked and the call silently dies. The fix is a working TURN server — see Make it work behind real-world NAT. The SDK delivers TURN credentials for you; Open Relay supplies the free relay.You're shipping a real product on
pk_live_alone — don't. Publishable-key auth is the zero-backend prototype path: it assigns a random peer ID per connection and carries no embedded TURN credentials. For stable peer identity, peer-visible metadata, or TURN-in-the-token, graduate to thetokenProvider(JWT) path — same SDK, just a token-minting endpoint behind it.You cached
remote.pcand it broke after a reconnect. The remote peer object survives a reconnect (same identity); the underlyingRTCPeerConnectiondoes not — it's swapped for a fresh one with new TURN credentials. Never hold a reference toremote.pcacross a drop; re-read it afterstatereturns toconnected.
Next steps
-
Add TURN before you ship. On
localhostyou won't notice, but symmetric NATs and corporate firewalls need a relay. Metered's Open Relay Project gives you 20 GB/month of free TURN with zero setup — the piece most WebRTC tutorials leave you to wire up yourself. -
Move to JWT auth when you need stable peer identity, peer-visible metadata, or TURN credentials delivered inside the token (the client reads them from the welcome message and
RTCPeerConnectionjust works). See the Realtime Messaging docs. -
Swap your camera mid-call without renegotiation. Use
peer.replaceTrack(oldTrack, newTrack)to switch from camera to screen-share (or to a different camera) without re-running the offer/answer dance; it fans the new track across the channel. -
Render every peer, not just one. This demo pins a single remote
<video>. For a real room, create a tile per remote peer insidepeer-joinedand remove it onpeer-left. - Going beyond ~6 peers? 1:1 and small groups are pure peer-to-peer; large rooms route media through an SFU — a separate media plane and a separate tutorial. Group calls, React, and React Native are out of scope here.
- Coming from an older peer-to-peer WebRTC library? The channel-vs-peer-ID mental shift, a full API mapping table, and porting pitfalls are in the official migration guide.
FAQ
Do I need to build a signaling server for a WebRTC video call?
Signaling is required, but you don't have to operate the server yourself. The signaling channel only exchanges SDP offers/answers and ICE candidates; media flows peer-to-peer afterward. You can hand-roll a Node/WebSocket server, or use a managed endpoint. @metered-ca/peer connects to a managed endpoint, so you write the call logic without running or scaling signaling infrastructure.
Why does my WebRTC call work locally but fail over the internet?
On a LAN, peers reach each other directly, so a missing TURN server goes unnoticed. Over the internet, NAT and firewalls block most direct connections — especially symmetric NAT — and the call silently fails. The fix is a properly configured TURN server. With @metered-ca/peer, TURN credentials are delivered to the client for you, so calls connect across real-world networks.
What is the difference between STUN and TURN?
STUN helps a peer discover its public address and attempt a direct connection; it's lightweight and free to run. TURN relays all media through a server when a direct connection is impossible, which happens behind symmetric NAT. WebRTC tries STUN first and falls back to TURN, so production video calls must configure both, not just STUN.
How do I keep a WebRTC call alive after the network drops?
Raw WebRTC doesn't reconnect on its own — you must detect ICE failure, trigger an ICE restart, and renegotiate. @metered-ca/peer automates this with three layers: WebSocket exponential backoff, an ICE-restart ladder, and channel reconciliation that preserves your peer references — so the call recovers from a network blip without you rewriting connection logic.
Is @metered-ca/peer free and open source?
Yes. @metered-ca/peer is published on npm under the MIT license with zero runtime dependencies. It connects to Metered's managed signaling endpoint, and the Open Relay Project provides 20 GB/month of free TURN bandwidth for prototypes. You can build and run a working 1:1 video call at no cost, no credit card required to start.
Recipe (for skimmers)
The whole call in three moves:
import { MeteredPeer } from "@metered-ca/peer";
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
const peer = new MeteredPeer({ apiKey: "pk_live_…" });
peer.on("peer-joined", ({ peer: remote }) => {
remote.on("stream-added", ({ stream }) => attachVideo(stream));
});
peer.addStream(localStream); // fans out to everyone in the channel
await peer.join("room-42"); // both tabs join the SAME channel
-
getUserMedia→addStreampublishes your camera to the whole channel — no per-peer dialing, nocall(id). -
peer-joined→ remote'sstream-addedhands you each peer's incoming media; point a<video>at it. -
join(channel)is the connect; identical code in both tabs, joining order does the handshake. Signaling is managed, TURN is delivered for you (free 20 GB/mo via Open Relay), and reconnection is automatic.
Install: npm install @metered-ca/peer · Docs: Realtime Messaging SDK · LLM reference: llms-realtime-messaging.txt · Free TURN: Open Relay
Last reviewed: 2026-06-01. Code verified against @metered-ca/peer v1.0.6 (MIT, ~13 KB gzipped, zero runtime deps), the Realtime Messaging SDK docs, and the live Open Relay free-tier page (20 GB/mo). The ~⅓-of-connections-need-TURN figure is a cited industry estimate, not a measured guarantee.




Top comments (1)
Thank you for reading. I hope you liked the article, let me know what you think below