WebRTC Fundamentals

· 5 min read · Updated April 12, 2026 · intermediate
javascript webrtc real-time p2p media

WebRTC lets you 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 more control, specify a deviceId or resolution:

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 sets its local description:

// 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:

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:

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.

See Also