In this guide
Video calls in a browser used to require plugins — Flash, then despair. Today you can build a working video call with web standards alone: the browser captures your camera, encodes the video, and streams it directly to the other person's browser. The technology is called WebRTC, and it is more approachable than its reputation suggests.
This guide explains what WebRTC is, the three APIs that matter, how two browsers find each other (signaling), and what trips up every beginner.
What WebRTC is
WebRTC (Web Real-Time Communication) is a set of browser APIs and protocols for peer-to-peer real-time media and data:
- Peer-to-peer — after setup, audio/video/data flows directly between browsers, not through your server. Lower latency, less server bandwidth.
- Real-time — optimized for latency over perfection; a dropped video frame is better than a delayed one.
- No plugins — built into every modern browser.
Note: "Peer-to-peer" describes the media path. You still need a server for signaling (introducing the peers) and usually for NAT traversal (STUN/TURN). WebRTC reduces server load; it does not eliminate servers.
The three APIs
1. getUserMedia — capture local media
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
// Attach to a <video> element for local preview
localVideo.srcObject = stream;
One call, and you have a MediaStream of camera and microphone. Constraints can specify resolution (video: { width: 1280, height: 720 }), facing mode on mobile, and more. Requires HTTPS (or localhost) — browsers block camera access on insecure origins.
2. RTCPeerConnection — the call itself
This is the core object: it manages the encrypted peer-to-peer connection, encoding, and network traversal.
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
// Add local tracks to the connection
stream.getTracks().forEach((track) => pc.addTrack(track, stream));
// Display the remote stream when it arrives
pc.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
addTrack puts your camera/mic into the connection; ontrack fires when the other side's media arrives. The iceServers config is for NAT traversal — explained below.
3. RTCDataChannel — arbitrary data peer-to-peer
The underrated API: a low-latency bidirectional data pipe between peers — no media involved.
const channel = pc.createDataChannel('chat');
channel.onopen = () => channel.send('Hello over WebRTC');
channel.onmessage = (event) => console.log('Received:', event.data);
Use it for in-call chat, file transfer, multiplayer game state — anything where server round-trips add unwanted latency.
Signaling: how peers find each other
Here is the part that confuses everyone: WebRTC does not define how peers exchange connection info. You build that yourself — this is called signaling, and a WebSocket server is the standard choice.
The exchange (simplified):
Caller Signaling Server Callee
| -- "I want to call Bob" --> | |
| -- "incoming call" --> |
| <-- "here is my offer" -- | <-- "here is my answer" -- |
| -- ICE candidates <------> | <-- ICE candidates --> |
| ==================== direct peer-to-peer media ==================== |
- Offer/answer — SDP (Session Description Protocol) blobs describing media capabilities: codecs, resolutions, encryption. Caller creates an offer; callee answers.
- ICE candidates — network addresses each peer discovers for itself ("I am reachable at these IPs/ports"). Exchanged until both sides find a working path.
The signaling server is just a message relay — it never sees the media. Any real-time channel works; WebSockets are the conventional pick.
NAT traversal: STUN, TURN, and why calls fail on some networks
Most devices sit behind NAT (your home router, campus firewall). Two NATed peers cannot directly address each other — this is where most student WebRTC demos die.
| Server | What it does | When you need it |
|---|---|---|
| STUN | Tells a peer its public address ("you appear as 1.2.3.4:5678") | Almost always — cheap, public STUN servers exist |
| TURN | Relays media when direct connection fails (peers send to TURN, TURN forwards) | Symmetric NATs, strict corporate/campus firewalls |
The rule of thumb: STUN succeeds for most home networks; TURN is the fallback that makes calls work everywhere — but TURN relays media through your server, so it costs bandwidth. For a class demo on one WiFi network, public STUN is enough. For a project that must work on arbitrary networks, budget for a TURN server (open-source Coturn runs fine on a small VPS).
Test on the networks your users actually use — "works on my home WiFi" is not validation.
Security properties (good news)
- Encryption is mandatory. WebRTC media uses DTLS-SRTP — unencrypted WebRTC does not exist. Eavesdropping the media path is not a realistic attack.
- HTTPS required for
getUserMedia— no camera access on plain HTTP. - Your signaling server needs auth. The media is encrypted, but call setup (who calls whom) goes through your server — authenticate it like any API. See JWT Authentication Explained.
Common architectures
- 1:1 calls (mesh) — two peers, direct connection. Simple; this guide's level.
- Small group calls (mesh) — every peer connects to every other peer. Works for 3–5 people; bandwidth grows quadratically and phones melt beyond that.
- SFU (Selective Forwarding Unit) — each peer sends one stream to a server, which forwards the needed streams to others. The professional answer for group calls (open-source options: LiveKit, Janus, mediasoup). Know the term; you probably do not need to build one for a student project.
Debugging: webrtc-internals and getStats
When a call fails or quality is bad, you need visibility into the connection. Two tools:
chrome://webrtc-internals— paste into Chrome during a call for live graphs: bitrate, packets lost, jitter, round-trip time, and the ICE candidate pairs being used. It is the single most useful WebRTC debugging page in existence.edge://webrtc-internalsworks in Edge; Firefox hasabout:webrtc.- The
getStats()API — programmatic access to the same data:const stats = await pc.getStats()returns a map of report objects (inbound-rtp, outbound-rtp, candidate-pair). Poll it during calls to build your own quality dashboard or to log diagnostics when users report "the call was choppy."
What to look at first: iceConnectionState (stuck at "checking" = NAT/firewall problem, reach for TURN), packetsLost fraction (rising = congestion; consider lowering resolution), and which candidate pair is selected (a relay pair means you are on TURN — working, but costing you bandwidth).
Common mistakes
- Skipping TURN, then "it works for me." Test across different networks (mobile data vs campus WiFi) early.
- No HTTPS in deployment.
getUserMediasilently fails on HTTP — a classic "works on localhost, broken on the server" bug. - Forgetting to stop tracks. Leaving a call without
stream.getTracks().forEach(t => t.stop())keeps the camera light on and the mic hot. - No reconnection handling. Network blips kill peer connections; listen for
connectionstatechangeand rebuild or notify. - Mesh for group calls. Five people in a full mesh is 20 media streams — fine on laptops, painful on phones. Cap mesh groups small or move to an SFU.
- Signaling without authentication. Anyone can call anyone, spam included. Auth your signaling.
Quick checklist
-
getUserMediaworking with local preview (HTTPS or localhost) - Signaling server relays offers, answers, and ICE candidates (WebSocket)
- STUN configured; TURN planned/tested for restrictive networks
- Remote stream renders; local tracks stop on hangup
- Tested on at least two different network types
- Signaling authenticated; no anonymous call setup
Where to go from here
- Build the signaling layer: WebSockets Explained: Build a Chat App.
- Authenticate call setup: JWT Authentication Explained.
- Serve over HTTPS: how to get a free SSL certificate.
- Real-time data alternative: MQTT protocol explained with examples.
- More web topics in the Web Development branch hub.