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

> How getUserMedia, RTCPeerConnection, and RTCDataChannel fit together: signaling, ICE candidates, rendering the remote stream in a video element, and hang-up.

**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.

## The core interfaces

- **`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.

## Capturing local media

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

```js
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.

## Signaling is not part of WebRTC

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:

```js
// 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.

### Then the ICE candidates

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()`:

```js
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

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:

```js
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.

## Playing the remote stream

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:

```js
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:

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

## Data channels

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:

```js
// 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.

## Watching the connection, and hanging up

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

```js
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

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:

```js
// 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.

## Where it is supported

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".

## Feature detection

```js
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 });
```

## Practical checklist

- [ ] 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.

## Where to go next

- [Web capabilities index](/reference/capabilities/) — other device and network-adjacent
  browser APIs.
- [WebTransport](/reference/capabilities/webtransport/) — another browser networking
  capability documented in this reference.
- [Screen capture](/reference/capabilities/screen-capture/) — how a page obtains a display
  `MediaStream`, the companion to `getUserMedia()` for screen sharing.
- [WebCodecs](/reference/capabilities/webcodecs/) — lower-level access to the codecs that
  WebRTC negotiates for you.
- [Document Picture-in-Picture](/reference/capabilities/document-picture-in-picture/) — how to
  move the `<video>` element playing a remote stream into an always-on-top window.