jsguides

WebRTC Fundamentals: Peer-to-Peer Media and Data in the Browser

WebRTC fundamentals give you the ability to stream audio, video, and arbitrary data directly between browsers. No plugins, no server relay for the media itself. You still need a small server for the initial handshake, but once peers are connected, everything goes peer-to-peer.

What problem does WebRTC solve?

Before WebRTC, real-time communication in browsers required Flash or a media relay server. WebRTC changes that. You can capture camera and microphone input, negotiate a direct connection with another browser, and exchange media or arbitrary data, all from JavaScript.

Common use cases include video calling, live streaming, file transfer, and multiplayer gaming.

Capturing Media with getUserMedia

The entry point for media input is navigator.mediaDevices.getUserMedia(). Call it with a constraints object that specifies what you want.

async function startCamera() {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: { width: 1280, height: 720 },
    audio: true
  });

  const video = document.querySelector('video');
  video.srcObject = stream;
  await video.play();
}

startCamera().catch(err => {
  console.error(`${err.name}: ${err.message}`);
});

The constraints object accepts booleans or advanced options. { video: true, audio: true } grabs whatever default devices are available. For production code, specifying ideal dimensions gives better results — the browser picks the closest available resolution without failing if the exact numbers are not supported. This is safer than setting exact values, which can cause the promise to reject if the camera does not match:

const stream = await navigator.mediaDevices.getUserMedia({
  video: { width: { ideal: 1280 }, height: { ideal: 720 } },
  audio: true
});

If the user denies permission, the promise rejects with NotAllowedError. If no matching device exists, you get NotFoundError. Remember that getUserMedia requires HTTPS; localhost is exempt.

Setting up a peer connection

The core of WebRTC is RTCPeerConnection. This object manages the entire lifecycle of a peer-to-peer connection.

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' }
  ]
});

The iceServers array tells WebRTC where to look for ICE candidates. STUN servers help discover your public IP address. For connections behind restrictive NATs or firewalls, you need a TURN server that relays traffic.

Creating an offer and answer

The signaling process uses SDP (Session Description Protocol). One peer creates an offer, the other answers.

// Caller side
async function call() {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  // Send offer to your signaling server (e.g., WebSocket to your backend)
  signalingServer.send({ type: 'offer', sdp: offer.sdp });
}

The receiving peer sets the remote description, creates an answer, and sends it back through the same signaling channel. The createAnswer method mirrors createOffer but runs on the other side of the connection. Both peers must exchange descriptions before any media can flow:

// Callee side
async function answer(offerSdp) {
  await pc.setRemoteDescription(new RTCSessionDescription({
    type: 'offer',
    sdp: offerSdp
  }));

  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);

  // Send answer back through your signaling channel
  signalingServer.send({ type: 'answer', sdp: answer.sdp });
}

Notice the RTCSessionDescription constructor takes an object with type ('offer' | 'answer' | 'rollback') and sdp (the session description string).

Exchanging ICE Candidates

ICE (Interactive Connectivity Establishment) finds the best path between two peers. It probes for candidates (local IP addresses and ports that might work) and these get exchanged alongside the SDP.

pc.onicecandidate = (event) => {
  if (event.candidate) {
    // Send candidate to remote peer via your signaling channel
    signalingServer.send({ type: 'ice-candidate', candidate: event.candidate });
  }
};

When you receive a candidate from the remote peer, add it to the connection. Each candidate represents a possible network path, and the browser tests them all to find the one with the lowest latency. Adding candidates is asynchronous and can fail if the remote description has not been set yet:

async function addIceCandidate(candidate) {
  try {
    await pc.addIceCandidate(new RTCIceCandidate(candidate));
  } catch (err) {
    console.error('Failed to add ICE candidate:', err);
  }
}

The RTCIceCandidate constructor accepts an object with candidate, sdpMid, sdpMLineIndex, and usernameFragment.

One common mistake is adding candidates before the remote description is set. That fails silently in some browsers. Always set setRemoteDescription first.

Signaling Server

WebRTC cannot work without some way to exchange SDP and ICE data between peers. There is no built-in mechanism for this; you must provide it. A WebSocket server is the most common choice. You can also use Firebase Realtime Database, SSE, or any bidirectional channel.

The signaling server only shuttles messages during setup. Once the connection is established, you can close the WebSocket and the peer-to-peer stream continues.

Sending data with RTCDataChannel

Beyond audio and video, WebRTC lets you send arbitrary data. Create a data channel from the peer connection:

const channel = pc.createDataChannel('chat', {
  ordered: true
});

channel.onopen = () => {
  channel.send('Connected!');
};

channel.onmessage = (event) => {
  console.log('Received:', event.data);
};

On the other side, listen for the datachannel event to receive the channel that the remote peer created. The channel object is the same on both ends, with identical methods for sending and receiving messages. This symmetry means you can write the same message-handling logic on the caller and callee sides:

pc.ondatachannel = (event) => {
  const receiveChannel = event.channel;
  receiveChannel.onmessage = (e) => console.log(e.data);
};

Data channels support options like ordered (guaranteed order) and maxRetransmits for unreliable delivery.

Browser compatibility and gotchas

WebRTC is supported in Chrome, Firefox, Safari, and Edge. However, differences exist:

  • Codec preferences vary. Firefox and Safari may negotiate different codecs than Chrome. For reliable cross-browser video, use a library like adapter.js that normalizes these differences.
  • TURN is essential for production. STUN works for simple setups on public IPs, but corporate firewalls and symmetric NATs block direct connections. A TURN relay ensures connectivity at the cost of some latency.
  • The icecandidateerror event fires when STUN/TURN requests fail. Listen for it to surface debugging information.
  • Check canTrickleIceCandidates before assuming a peer can accept incremental candidates. Most modern browsers support it.
  • Chrome requires HTTPS for getUserMedia. Firefox does too from version 68 onward.

Putting it together

Here is a minimal end-to-end example that captures video and sets up a peer connection:

const pc = new RTCPeerConnection({
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});

// Capture local video
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));

// Handle incoming tracks
pc.ontrack = (event) => {
  const [remoteStream] = event.streams;
  document.querySelector('video').srcObject = remoteStream;
};

// Start the call by creating an offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

// Exchange offer/answer via your signaling server, then add ICE candidates as they arrive

From here, exchange the offer and answer through your signaling channel, add ICE candidates as they arrive, and the browser handles the rest. WebRTC takes care of encoding, latency, and congestion control automatically.

Signaling is separate

WebRTC does not define your meeting point. The app still needs a signaling channel to exchange offers, answers, and ICE candidates. Keeping signaling separate from media flow gives you more freedom later, because you can swap the transport without rewriting the peer connection logic. It also makes failures easier to isolate.

Media and data can coexist

A call is not limited to video. You can send tracks, plain text, or control messages through the same peer connection, which makes the API useful for collaboration tools and games as well as calls. Choosing which channel carries which kind of data keeps the app easier to understand.

Real networks are messy

Local testing often works too well. Real users sit behind NATs, proxies, and changing networks, so TURN support and error handling matter more than the demo usually suggests. Treat connection setup as a negotiation rather than a guarantee, and your code will be calmer when the environment is not.

Debug the call flow

When a connection fails, inspect the order of the signaling steps first. Offer, answer, and candidates must arrive in a sequence the browser can use. Once the sequence is correct, turn to codec choice, permissions, and network relays. Most WebRTC bugs become easier once you separate setup from transport.

Signal carefully

Offer, answer, and candidate messages are the coordination layer for a call. Keep that traffic small, ordered, and easy to log so the browser can finish setup without guesswork. Clear signaling makes the rest of the connection easier to debug.

Expect different paths

Two users can end up with different routes through the network, even when they start from the same page. That means STUN, TURN, and codec choices are part of normal operation, not edge cases. Planning for those differences keeps the app from depending on the easiest lab setup.

Keep signaling logs readable

When setup fails, readable logs save time. Log the type of each signaling message, the order it arrived in, and whether the peer accepted it. That simple trail gives you a much clearer picture of where the call drifted off course.

Treat connectivity as probabilistic

WebRTC works best when the app expects some attempts to fail before a connection settles. That is normal on real networks, and it is why retries and fallback paths matter. Treating connectivity as probabilistic keeps the code calmer when one peer has a harder route than the other.

See Also