Skip to content

WebRTC: peer-to-peer audio, video, and data

Published Updated

In one line: WebRTC, together with the Media Capture and Streams API, gives the web audio and video conferencing, file exchange, and screen sharing — connections between peers can be made without special drivers or plug-ins, and often without any intermediary server once the connection is set up.

  • RTCPeerConnection represents a WebRTC connection between the local computer and a remote peer; once established and opened, media streams and/or data channels can be added to it.
  • MediaStreamTrack represents a single track of media data within a stream — audio, video, or text.
  • RTCDataChannel, added to an open RTCPeerConnection, carries arbitrary application data directly between peers alongside (or instead of) media.

MediaDevices.getUserMedia() requests the media types named by its constraints — audio, video, or both — and resolves with a MediaStream:

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

navigator.mediaDevices.enumerateDevices() lists available input devices. Passing a device’s deviceId as a bare constraint value is only a preference the browser may override; to mandate that specific device, pass it as { exact: deviceId } instead.

WebRTC cannot create a connection without first exchanging session information — offers, answers, and network candidates — between the two peers, and MDN is explicit that this exchange needs “some sort of server in the middle”: WebRTC does not define this signal channel itself, so it can be carried over any transport the app already has (WebSocket, an existing API, etc.). A typical flow:

// Both are per-call lifecycle state, so declare them with `let`: hang-up sets
// `pc` back to null, and starting the next call replaces both. With `const`,
// that reassignment throws `TypeError: Assignment to constant variable`.
let pc = new RTCPeerConnection();
// One controller for every listener this call registers. Hang-up aborts it to
// detach them all at once — see "Watching the connection, and hanging up".
let callAbort = new AbortController();
pc.addTrack(stream.getTracks()[0], stream);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send `offer` to the remote peer over your own signaling channel.

The receiving peer calls setRemoteDescription() with that offer, creates its own answer, and sends it back over the same signaling channel — after which the two RTCPeerConnection instances negotiate media/data flow between them, often (but not always) without any server relaying the media itself.

Offer and answer settle which codecs to use; they do not settle how the packets get across the network. That is ICE (Interactive Connectivity Establishment). Each peer emits its candidates via icecandidate events, in the order they are discovered, and keeps emitting them until it runs out of suggestions — even after media has already started streaming. Your only job is to forward each candidate over the same signaling channel and hand incoming ones to addIceCandidate():

pc.addEventListener('icecandidate', ({ candidate }) => {
if (candidate) signaling.send({ type: 'new-ice-candidate', candidate });
}, { signal: callAbort.signal });
// On the receiving side, after setRemoteDescription() has been called:
await pc.addIceCandidate(incoming.candidate);

MDN states the responsibility plainly: during ICE negotiation your code only has to accept outgoing candidates from the ICE layer and send them across the signaling connection when your onicecandidate handler runs, and deliver incoming candidate messages to your ICE layer by calling addIceCandidate(). The contents of the SDP are irrelevant to you in essentially all cases — and to the signaling server the message is a black box it need not interpret at all.

Let the browser tell you when to negotiate

Section titled “Let the browser tell you when to negotiate”

Calling createOffer() by hand, as above, is the short version. In MDN’s own call flow the offer is not created on a timer or a button press: once the caller has created its RTCPeerConnection, created a media stream and added its tracks to the connection, the browser delivers a negotiationneeded event to the RTCPeerConnection to say it is ready to begin negotiating with the other peer. Driving the offer from that event is what keeps a later change — a track added mid-call, a renegotiated format — from silently going un-negotiated:

pc.addEventListener('negotiationneeded', async () => {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: 'video-offer', sdp: pc.localDescription });
}, { signal: callAbort.signal });

Once setLocalDescription() fulfills, the ICE agent begins sending the icecandidate events from the previous section.

A WebRTC “player” is just a <video> element pointed at the stream the remote peer sent. When new tracks are added to the RTCPeerConnection — either because the other side called addTrack(), or because the stream’s format was renegotiated — a track event is sent to the connection for each track added. Attaching the incoming media to an HTML element is the common need, and MDN’s handler is one line:

function handleTrackEvent(event) {
document.getElementById('received_video').srcObject = event.streams[0];
document.getElementById('hangup-button').disabled = false;
}
pc.addEventListener('track', handleTrackEvent, { signal: callAbort.signal });

Once that has run, the video the other peer is sending is displayed in the local browser window. HTMLMediaElement.srcObject is the property doing the work: it sets or returns the object serving as the source of the element’s media, and accepts a MediaStream, a MediaSource, a Blob, or a File — so the same element can later be repointed at a local getUserMedia() stream without touching src.

Notice that the handler never calls play(). That is not because assignment implies playback in general — it is because the receiving element in MDN’s sample carries the autoplay attribute, so the stream starts on its own once it is attached. If your element does not, starting playback is your job and is subject to the browser’s autoplay policy.

The other thing to handle is a browser old enough to lack srcObject, where MDN’s documented fallback is an object URL — and it is explicit that this is legacy, not a choice:

if ('srcObject' in video) {
video.srcObject = remoteStream;
} else {
// Avoid using this in new browsers, as it is going away.
video.src = URL.createObjectURL(remoteStream);
}

An RTCPeerConnection does not have to carry media at all. RTCDataChannel is a network channel for bidirectional peer-to-peer transfer of arbitrary data; every data channel belongs to a peer connection, and each connection can have up to a theoretical maximum of 65,534 data channels (the real limit varies by browser). The initiating side calls createDataChannel(); the remote side learns about it through a datachannel event:

// Caller
const channel = pc.createDataChannel('chat');
channel.addEventListener('open', () => channel.send('hello'));
// Callee
pc.addEventListener('datachannel', (event) => {
event.channel.addEventListener('message', (e) => render(e.data));
}, { signal: callAbort.signal });

Two defaults are worth knowing. Delivery is ordered by default (ordered is true), and binary data arrives as an ArrayBuffer unless you set binaryType to 'blob'. Before sending anything, check readyState — a channel is only usable while it reads open (the other values are connecting, closing and closed) — and watch bufferedAmount if you push large volumes, since it reports the bytes still queued to go out.

The ICE layer sends an iceconnectionstatechange event when the connection state changes — including when the far end terminates the call. MDN’s sample tears the call down when iceConnectionState becomes "closed" or "failed", and deliberately does not watch "disconnected": that state can indicate a temporary problem and may go back to "connected" after some time. A signalingState of "closed" is watched too, for backward compatibility — that value has been deprecated in favour of the "closed" iceConnectionState. There is also icegatheringstatechange, which tells you when candidate gathering changes state and is useful for debugging or for detecting that collection has finished.

Closing the call is more than calling close(). MDN’s closeVideoCall() removes the connection’s event handlers, stops every track on both the remote and local streams with MediaStreamTrack.stop(), closes the RTCPeerConnection, sets the peer-connection variable to null, and only afterward clears the video elements’ src/srcObject:

function closeVideoCall() {
if (!pc) return;
// Detaches every listener registered with `{ signal: callAbort.signal }` above.
// Note that MDN's older sample nulls out the `on*` properties instead; that only
// clears handlers assigned *through those properties* and leaves listeners added
// with addEventListener() attached — a stray `negotiationneeded` could then still
// fire against a `pc` this function has already set to null.
callAbort.abort();
for (const el of [remoteVideo, localVideo]) {
if (el.srcObject) el.srcObject.getTracks().forEach((track) => track.stop());
}
pc.close();
// `pc` and `callAbort` are the `let` bindings from call setup. Nulling `pc` is what
// makes the `if (!pc) return` guard above idempotent, so a second hang-up — from the
// button and from `iceconnectionstatechange` — is a no-op instead of a second teardown.
pc = null;
for (const el of [remoteVideo, localVideo]) {
el.removeAttribute('src');
// `srcObject` is an IDL attribute that does not reflect to a content attribute,
// so removeAttribute('srcObject') is a no-op. Assigning null is what releases
// the element's reference to the stream.
el.srcObject = null;
}
}
// Starting the next call replaces both bindings: a controller that has already been
// aborted stays aborted, so reusing it would detach every new listener immediately.
function startNewCall() {
pc = new RTCPeerConnection();
callAbort = new AbortController();
return pc;
}

Two details decide whether this actually releases anything. The listeners have to come off the way they went on: if you registered them with addEventListener(), setting the matching on* property to null removes nothing, so either keep named callbacks and call removeEventListener(), or — as above — give every registration the same AbortSignal and abort it once. And the element’s stream reference is cleared by assigning el.srcObject = null; the HTML Standard names that assignment as the way to release it, because srcObject is a plain IDL attribute with no content attribute for removeAttribute() to touch.

The AbortSignal shortcut is newer than WebRTC

Section titled “The AbortSignal shortcut is newer than WebRTC”

The one-abort teardown used throughout this page is not available everywhere WebRTC is. MDN’s compatibility data records the signal option of addEventListener() as added in Chrome 90, Edge 90, Firefox 86 and Safari 15, while the RTCPeerConnection() constructor has been widely available since September 2017. In a browser older than those floors the option is simply not recognised, so callAbort.abort() detaches nothing and the stray callbacks this section is about come straight back. If your support range reaches below them, register named callbacks and remove them explicitly instead:

// Uses a named callback so teardown does not depend on `options.signal`.
const onIceCandidate = ({ candidate }) => {
if (candidate) signaling.send({ type: 'new-ice-candidate', candidate });
};
pc.addEventListener('icecandidate', onIceCandidate);
// …then in closeVideoCall(), before pc.close():
pc.removeEventListener('icecandidate', onIceCandidate);

This fallback does not construct or dereference an AbortController; it keeps the named callback and removes the listener explicitly.

MDN describes the purpose of this routine as shutting down and resetting the connection and releasing resources — which is why the per-track stop() calls are part of it and not an optional extra, and why the handlers come off first: it prevents stray events while the connection is closing.

Per MDN’s compatibility data, the RTCPeerConnection() constructor has Baseline “Widely available” status: the feature has worked across major browsers since September 2017.

That is older than one convenience this page’s examples use. The signal option of addEventListener() is recorded in MDN’s compatibility data as added in Chrome 90, Edge 90, Firefox 86 and Safari 15, so the single-abort() teardown holds only from those versions on; below them, detach listeners with removeEventListener() as shown under “The AbortSignal shortcut is newer than WebRTC”.

  • Legend
  • Yes
  • Partial
  • Flag
  • No
  • Unknown
Browser / PlatformSupportVersionsConfidenceSourceNotes
Chrome (Desktop)Yes56highsource—
Chrome (Android)Yes56highsource1
Edge (Desktop)Yes15highsource—
Firefox (Desktop)Yes44highsource—
Firefox (Android)Yes44highsource—
Safari (macOS)Yes11highsource—
Safari (iOS)Yes11highsource2
Samsung InternetYes6.0highsource3
WebView (Android)Yes56highsource4
  1. Derived by browser-compat-data mirroring from Chrome.
  2. Derived by browser-compat-data mirroring from Safari.
  3. Derived by browser-compat-data mirroring from Chrome Android.
  4. Derived by browser-compat-data mirroring from Chrome Android.

Source data: /compatibility/web-rtc.json · Global usage: 93 % (StatCounter 2026-05)

Source: spec · MDN · Last verified 2026-10-03 · Confidence: high (computed from sources)

async function startCall(constraints) {
if (!('RTCPeerConnection' in window) || !navigator.mediaDevices?.getUserMedia) {
// This call flow needs both the peer connection and local capture.
// Without them, fall back to a non-realtime path (e.g. an upload form)
// instead of attempting a call.
return null;
}
const stream = await navigator.mediaDevices.getUserMedia(constraints);
const connection = new RTCPeerConnection();
stream.getTracks().forEach((track) => connection.addTrack(track, stream));
return connection;
}
// Assign into the call-lifecycle `pc` from the signaling section; `null` means no
// call was started, so there is nothing for closeVideoCall() to tear down.
pc = await startCall({ audio: true, video: true });
  • Feature-detect both RTCPeerConnection and navigator.mediaDevices.getUserMedia before starting a call, and provide a fallback path when either is missing.
  • Call getUserMedia() only in response to a context the user understands. User permission is required, but the request can be rejected without showing a prompt.
  • Build your own signaling channel; WebRTC does not provide one, only the connection once offer/answer/candidates have been exchanged.
  • Add tracks to RTCPeerConnection before creating the offer so they’re included in the negotiated session.
  • Use enumerateDevices() plus a deviceId: { exact: deviceId } constraint when you need to mandate a specific camera or microphone rather than the default device.
  • Keep relaying ICE candidates after the call appears to be up — peers keep sending them even once media is flowing, and dropping late ones can cost you a better candidate.
  • Deliver each incoming candidate to addIceCandidate() and forward each outgoing one verbatim — do not try to interpret the candidate’s SDP string.
  • Gate send() on a data channel’s readyState === 'open', and watch bufferedAmount instead of pushing bulk data blindly.
  • Drive the offer from the negotiationneeded event rather than only at call setup, so a track added mid-call or a renegotiated format still gets negotiated.
  • Expect one track event per incoming track, not one per call — attach each to the element that should render it instead of assuming a single fire.
  • Prefer srcObject for the remote stream; only fall back to URL.createObjectURL(stream) for browsers that lack it, since MDN marks that path as going away.
  • Do not tear the call down on iceConnectionState === 'disconnected' — it can be a temporary problem and may return to 'connected'. Act on 'closed' and 'failed'.
  • On hang-up, remove the connection’s event handlers, stop() every track on both streams, call close(), and clear the video elements’ src/srcObject — close() alone does not release the resources.
  • Detach listeners the same way you attached them: nulling pc.onnegotiationneeded does not remove an addEventListener('negotiationneeded', …) registration. Use one AbortSignal for every listener and abort it on hang-up, or keep named callbacks for removeEventListener().
  • Do not rely on { signal } alone if you support browsers below Chrome/Edge 90, Firefox 86 or Safari 15 — WebRTC is older than that option, and a browser that does not recognise it ignores it, so abort() leaves every listener attached. Keep named callbacks and removeEventListener() for that range.
  • Hold the connection and its AbortController in let bindings, not const: hang-up assigns pc = null to make the teardown idempotent, and the next call needs a fresh controller because an aborted one detaches new listeners the moment they register.
  • Clear a media element’s stream with el.srcObject = null, not removeAttribute('srcObject') — srcObject has no content attribute, so the removeAttribute() call silently does nothing and the stream stays referenced.
  • Web capabilities index — other device and network-adjacent browser APIs.
  • WebTransport — another browser networking capability documented in this reference.
  • Screen capture — how a page obtains a display MediaStream, the companion to getUserMedia() for screen sharing.
  • WebCodecs — lower-level access to the codecs that WebRTC negotiates for you.
  • Document Picture-in-Picture — how to move the <video> element playing a remote stream into an always-on-top window.