WebRTC in HTML and JavaScript: a minimal example and why it fails

WebRTC here means the browser JavaScript API behind RTCPeerConnection, not a hosting service. A connection needs three things from you: the right call order, a way to pass messages between the peers, and sometimes a relay server.

WebRTC is the set of browser APIs for sending video, audio and data directly between two browsers.

When it fails, this page covers five causes: the call order, missing signaling, no route between the peers, an insecure page for the camera, and a video element nobody connected.

The smallest working version needs no camera and no server. It puts two peers in one page and sends text between them. Press the button, then type in either box.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebRTC data channel, two peers in one page</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  button:disabled { background: #9aa3b2; cursor: default; }
  #state { font-size: 13px; color: #374151; }
  .peers { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  .peer { background: #fff; border-radius: 10px; padding: 10px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
  .peer h2 { margin: 0 0 6px; font-size: 14px; }
  .log { height: 130px; overflow-y: auto; font-size: 13px; background: #f9fafb; border-radius: 6px; padding: 6px; margin-bottom: 8px; }
  .log div { margin: 2px 0; }
  .me { color: #1d4ed8; } .them { color: #0f5132; }
  form { display: flex; gap: 6px; }
  input { flex: 1; min-width: 0; font: inherit; padding: 7px; border: 1px solid #d5d9e0; border-radius: 6px; }
  form button { padding: 7px 10px; }
  @media (max-width: 480px) { .peers { grid-template-columns: 1fr; } .log { height: 70px; } }
</style>
</head>
<body>
<div class="bar">
  <button id="connect">Connect A and B</button>
  <span id="state">not connected</span>
</div>
<div class="peers">
  <div class="peer"><h2>Peer A</h2><div class="log" id="logA"></div>
    <form id="formA"><input id="inA" placeholder="Message to B" autocomplete="off" disabled><button disabled>Send</button></form></div>
  <div class="peer"><h2>Peer B</h2><div class="log" id="logB"></div>
    <form id="formB"><input id="inB" placeholder="Message to A" autocomplete="off" disabled><button disabled>Send</button></form></div>
</div>

<script>
  const $ = (id) => document.getElementById(id);
  let chanA, chanB;

  function line(log, cls, text) {
    const d = document.createElement('div');
    d.className = cls; d.textContent = text;
    $(log).appendChild(d);
    $(log).scrollTop = $(log).scrollHeight;
  }

  function wire(chan, side, other) {
    chan.onopen = () => {
      document.querySelectorAll('#form' + side + ' input, #form' + side + ' button')
        .forEach((el) => (el.disabled = false));
    };
    chan.onmessage = (e) => line('log' + side, 'them', other + ': ' + e.data);
  }

  $('connect').addEventListener('click', async () => {
    $('connect').disabled = true;
    const a = new RTCPeerConnection();
    const b = new RTCPeerConnection();

    // Each side hands its ICE candidates to the other. Here the "network" is just a function call.
    a.onicecandidate = (e) => e.candidate && b.addIceCandidate(e.candidate);
    b.onicecandidate = (e) => e.candidate && a.addIceCandidate(e.candidate);
    a.onconnectionstatechange = () => ($('state').textContent = 'connection: ' + a.connectionState);

    chanA = a.createDataChannel('chat');          // create the channel BEFORE createOffer
    wire(chanA, 'A', 'B');
    b.ondatachannel = (e) => { chanB = e.channel; wire(chanB, 'B', 'A'); };

    // The offer/answer exchange. Normally these two objects travel through a server you write.
    await a.setLocalDescription(await a.createOffer());
    await b.setRemoteDescription(a.localDescription);
    await b.setLocalDescription(await b.createAnswer());
    await a.setRemoteDescription(b.localDescription);
  });

  function send(form, input, chan, log) {
    $(form).addEventListener('submit', (e) => {
      e.preventDefault();
      const text = $(input).value.trim();
      if (!text || chan().readyState !== 'open') return;  // send() throws unless the channel is open
      chan().send(text);
      line(log, 'me', 'me: ' + text);
      $(input).value = '';
    });
  }
  send('formA', 'inA', () => chanA, 'logA');
  send('formB', 'inB', () => chanB, 'logB');
</script>
</body>
</html>
Two RTCPeerConnection objects in one page, joined by a data channel. Press Connect A and B, then send a message from either side.

What the API gives you

Three objects do the work. Each one is described in the MDN documentation of the WebRTC API.

  • RTCPeerConnection represents the connection between your browser and a remote peer.
  • RTCDataChannel is a two-way channel for any data between the peers.
  • MediaStream holds media tracks, such as the video from a camera.

The data channel is the easiest place to start, because it needs no camera permission. The same connection object carries media tracks when you want video.

Signaling: the part you write

Two browsers on different networks cannot find each other alone. They first swap an offer, an answer and ICE candidates. This swap is called signaling, and it goes through a third party that both sides can reach.

Signaling carries the offer, the answer and the candidates. Data and media then go straight between the peers.
Signaling carries the offer, the answer and the candidates. Data and media then go straight between the peers.

WebRTC does not specify how signaling travels. The MDN guide names WebSocket and fetch() as options. In the loopback example above, signaling is a plain function call, because both peers live in the same page.

On a real site, the browser side can look like this, with a server of your own behind it:

// A signaling channel is just a way to pass JSON. The URL is a placeholder.
const signal = new WebSocket('wss://signal.example.org/room/42');

pc.onicecandidate = (e) => {
  if (e.candidate) signal.send(JSON.stringify({ candidate: e.candidate }));
};

signal.onmessage = async (msg) => {
  const data = JSON.parse(msg.data);
  if (data.candidate) await pc.addIceCandidate(data.candidate);
  if (data.description) await pc.setRemoteDescription(data.description);
};

In this sketch the server only forwards each message to the other peer. The media does not pass through it.

Call the methods in this order

The offer and the answer follow one fixed sequence on two sides.

Caller and callee, numbered in the order the calls happen.
Caller and callee, numbered in the order the calls happen.
  1. Caller: createDataChannel() or addTrack(), so the offer has something to describe.
  2. Caller: createOffer(), then setLocalDescription(offer), then send the offer.
  3. Callee: setRemoteDescription(offer).
  4. Callee: createAnswer(), then setLocalDescription(answer), then send the answer back.
  5. Caller: setRemoteDescription(answer).

Order matters for step 1. The MDN page for createDataChannel says that adding the first channel starts a renegotiation, and addTrack does the same. If you add them after the offer, you need a second offer and answer round.

On the callee, the channel arrives in the ondatachannel event and tracks arrive in the track event. Do not create the channel again there.

Send video, not just data

Video uses the same exchange. Instead of a data channel, add a track on the sender and show it in the track event on the receiver. A canvas is a handy source because it needs no camera: canvas.captureStream() returns a stream with one video track.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebRTC video without a camera</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
  button { font: inherit; padding: 8px 14px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  button:disabled { background: #9aa3b2; cursor: default; }
  #state { font-size: 13px; color: #374151; }
  .pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  figure { margin: 0; background: #fff; border-radius: 10px; padding: 8px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
  figcaption { font-size: 13px; margin-bottom: 6px; font-weight: 600; }
  canvas, video { width: 100%; aspect-ratio: 4 / 3; display: block; background: #e5e7eb; border-radius: 6px; }
  #info { font-size: 13px; margin-top: 10px; color: #374151; }
  @media (max-width: 480px) { figure { padding: 6px; } figcaption { font-size: 12px; } }
</style>
</head>
<body>
<div class="bar">
  <button id="start">Send canvas video</button>
  <button id="stop" disabled>Hang up</button>
  <span id="state">idle</span>
</div>
<div class="pair">
  <figure><figcaption>Sender: a canvas</figcaption><canvas id="cv" width="320" height="240"></canvas></figure>
  <figure><figcaption>Receiver: a video</figcaption><video id="remote" autoplay muted playsinline></video></figure>
</div>
<div id="info"></div>

<script>
  const $ = (id) => document.getElementById(id);
  const cv = $('cv'), ctx = cv.getContext('2d');
  let pcs = [], t = 0, raf;

  // Something to send: a moving ball on a canvas
  function draw() {
    t += 0.04;
    ctx.fillStyle = '#1e293b'; ctx.fillRect(0, 0, 320, 240);
    ctx.fillStyle = '#facc15';
    ctx.beginPath();
    ctx.arc(160 + Math.cos(t) * 100, 120 + Math.sin(t * 1.3) * 70, 24, 0, 7);
    ctx.fill();
    ctx.fillStyle = '#fff'; ctx.font = '16px system-ui'; ctx.fillText('frame ' + Math.round(t * 25), 10, 22);
    raf = requestAnimationFrame(draw);
  }
  draw();

  $('start').addEventListener('click', async () => {
    $('start').disabled = true;
    const a = new RTCPeerConnection(), b = new RTCPeerConnection();
    pcs = [a, b];
    a.onicecandidate = (e) => e.candidate && b.addIceCandidate(e.candidate);
    b.onicecandidate = (e) => e.candidate && a.addIceCandidate(e.candidate);
    a.onconnectionstatechange = () => {
      $('state').textContent = 'connection: ' + a.connectionState;
    };

    // Receiver: show the incoming stream
    b.ontrack = (e) => {
      $('remote').srcObject = e.streams[0];
      $('info').textContent = 'A ' + e.track.kind + ' track arrived.';
    };

    // Sender: add the track BEFORE createOffer so the offer contains a video line
    const stream = cv.captureStream(25);
    stream.getTracks().forEach((track) => a.addTrack(track, stream));

    await a.setLocalDescription(await a.createOffer());
    await b.setRemoteDescription(a.localDescription);
    await b.setLocalDescription(await b.createAnswer());
    await a.setRemoteDescription(b.localDescription);
    $('stop').disabled = false;
  });

  $('stop').addEventListener('click', () => {
    pcs.forEach((pc) => pc.close());
    $('remote').srcObject = null;
    $('state').textContent = 'connection: closed';
    $('info').textContent = '';
    $('stop').disabled = true;
    $('start').disabled = false;
  });
</script>
</body>
</html>
The sender draws on a canvas, captureStream turns it into a track, and the receiver shows it in a video element.
// sender: add the track before createOffer
const stream = canvas.captureStream(25);
stream.getTracks().forEach((t) => pc.addTrack(t, stream));

// receiver: show whatever arrives
pc2.ontrack = (e) => { video.srcObject = e.streams[0]; };

To send a live camera instead, replace captureStream with a stream from getUserMedia. The camera guide covers permission, errors and turning the camera off. To record a stream, see MediaRecorder. Canvas drawing basics are in drawing on a canvas.

Across networks: STUN and TURN

Two tabs on one computer can reach each other. Two people on different routers may not be able to. ICE is the part of WebRTC that finds a route, and it uses STUN and TURN servers.

STUN helps each peer find its public address. TURN relays the traffic when a direct path is refused.
STUN helps each peer find its public address. TURN relays the traffic when a direct path is refused.

You list the servers when you create the connection. The URL below is a placeholder; use the servers your provider gives you.

const pc = new RTCPeerConnection({
  iceServers: [{ urls: 'stun:stun.example.org' }],
});

In our test, two connections in one page, and two tabs of one Chromium browser, both connected with no iceServers at all. That says nothing about two different networks. There, plan for a STUN server and a TURN server.

Read the connection state first

When nothing happens, print pc.connectionState and listen for the connectionstatechange event. It tells you whether the problem is before the connection, during it or after.

connectionState Meaning
new Nothing has started connecting yet
connecting One or more transports are still being set up
connected Every transport in use is up
disconnected At least one transport has lost its link
failed One or more transports have failed
closed The connection is closed

If the state never leaves new, check that both sides ran setRemoteDescription. If it ends in failed, a transport failed, which is where STUN and TURN come in.

A finished example: chat with copy-paste signaling

This version has no loopback and no server. You are the signaling channel. Open the page in two tabs, copy the text from one tab, paste it into the other, and send the reply back.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebRTC chat with copy-paste signaling</title>
<style>
  * { box-sizing: border-box; }
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  h1 { font-size: 16px; margin: 0 0 4px; }
  p.hint { margin: 0 0 10px; font-size: 13px; color: #4b5563; line-height: 1.45; }
  .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
  .box { background: #fff; border-radius: 10px; padding: 10px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
  label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 4px; }
  textarea { width: 100%; height: 84px; font: 11px/1.3 ui-monospace, Consolas, monospace; border: 1px solid #d5d9e0; border-radius: 6px; padding: 6px; resize: none; }
  .row { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
  button { font: inherit; font-size: 13px; padding: 7px 11px; border: 0; border-radius: 8px; background: #2563eb; color: #fff; cursor: pointer; }
  button:disabled { background: #9aa3b2; cursor: default; }
  button.alt { background: #475569; }
  #state { margin: 10px 0; font-size: 13px; color: #374151; }
  #log { height: 110px; overflow-y: auto; font-size: 13px; background: #fff; border-radius: 8px; padding: 6px; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
  #log div { margin: 2px 0; }
  .me { color: #1d4ed8; } .them { color: #0f5132; }
  form { display: flex; gap: 6px; margin-top: 8px; }
  input { flex: 1; min-width: 0; font: inherit; padding: 7px; border: 1px solid #d5d9e0; border-radius: 6px; }
  @media (max-width: 480px) { .cols { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>Chat with no server: copy-paste signaling</h1>
<p class="hint">Open this page in two tabs. Tab 1 presses <b>Create offer</b> and sends the text to tab 2. Tab 2 pastes it, presses <b>Use their code</b>, and sends its text back. Tab 1 pastes that and presses <b>Use their code</b>.</p>

<div class="cols">
  <div class="box">
    <label for="mine">Your code (copy this)</label>
    <textarea id="mine" readonly></textarea>
    <div class="row"><button id="make">Create offer</button><button id="copy" class="alt">Copy</button></div>
  </div>
  <div class="box">
    <label for="theirs">Their code (paste here)</label>
    <textarea id="theirs"></textarea>
    <div class="row"><button id="use">Use their code</button></div>
  </div>
</div>

<div id="state">not connected</div>
<div id="log"></div>
<form id="form">
  <input id="msg" placeholder="Type a message" autocomplete="off" disabled>
  <button disabled>Send</button>
</form>

<script>
  const $ = (id) => document.getElementById(id);
  const pc = new RTCPeerConnection();   // no iceServers: fine for two tabs on one machine, not for across networks
  let chan = null, madeOffer = false;

  function line(cls, text) {
    const d = document.createElement('div');
    d.className = cls; d.textContent = text;
    $('log').appendChild(d);
    $('log').scrollTop = $('log').scrollHeight;
  }

  function setChannel(c) {
    chan = c;
    c.onopen = () => document.querySelectorAll('#form input, #form button').forEach((el) => (el.disabled = false));
    c.onmessage = (e) => line('them', 'them: ' + e.data);
  }

  pc.onconnectionstatechange = () => ($('state').textContent = 'connection: ' + pc.connectionState);
  pc.ondatachannel = (e) => setChannel(e.channel);

  // Wait until ICE gathering finishes, so the description already contains every candidate.
  // Then one pasted blob is all the other side needs.
  function gathered() {
    return new Promise((resolve) => {
      if (pc.iceGatheringState === 'complete') return resolve();
      pc.addEventListener('icegatheringstatechange', () => {
        if (pc.iceGatheringState === 'complete') resolve();
      });
    });
  }

  $('make').addEventListener('click', async () => {
    madeOffer = true;
    setChannel(pc.createDataChannel('chat'));
    await pc.setLocalDescription(await pc.createOffer());
    await gathered();
    $('mine').value = JSON.stringify(pc.localDescription);
    $('state').textContent = 'offer ready: send it, then paste the answer you get back';
  });

  $('use').addEventListener('click', async () => {
    let desc;
    try { desc = JSON.parse($('theirs').value); } catch (e) { $('state').textContent = 'that is not a valid code'; return; }
    await pc.setRemoteDescription(desc);
    if (!madeOffer) {                       // we are the joining side: answer the offer
      await pc.setLocalDescription(await pc.createAnswer());
      await gathered();
      $('mine').value = JSON.stringify(pc.localDescription);
      $('state').textContent = 'answer ready: send it back';
    }
  });

  $('copy').addEventListener('click', () => {
    $('mine').select();
    document.execCommand('copy');           // if blocked, the text is selected: copy it by hand
  });

  $('form').addEventListener('submit', (e) => {
    e.preventDefault();
    const text = $('msg').value.trim();
    if (!text || !chan || chan.readyState !== 'open') return;
    chan.send(text);
    line('me', 'me: ' + text);
    $('msg').value = '';
  });
</script>
</body>
</html>
Open it in two tabs. Tab 1 creates an offer, tab 2 pastes it and returns an answer, tab 1 pastes the answer.
  • One blob per side: the code waits for iceGatheringState to reach complete, so the pasted description already holds every candidate. MDN recommends watching icegatheringstatechange for this.
  • Same button, two roles: Use their code sets the remote description. If the page did not make the offer, it also creates the answer.
  • Wait before sending: the form checks that the channel is open, because send() throws InvalidStateError on any other state.

When it does not work

What you see Cause Fix
navigator.mediaDevices is undefined The page is not on HTTPS or localhost Serve it over HTTPS
NotAllowedError from the camera Permission denied, or a frame without allow="camera" Check site settings and the iframe tag
Nothing happens after createOffer The offer never reached the other peer Send it through your signaling
No ondatachannel or track event The channel or track was added after the offer Add it before createOffer, or renegotiate
send() throws InvalidStateError The channel is not open yet Send from the open event or check readyState
State goes connecting, then failed No working route between the peers Add STUN, then TURN
Pasted code fails on the other side It was read before ICE gathering finished Wait for iceGatheringState to be complete
Remote video is empty Nothing set srcObject Set it from e.streams[0] in track

For the first two rows, the camera guide shows each error name. A page inside an iframe also depends on the frame's permissions; see the sandbox attribute.

A connection demo is hard to describe. Readers need to press the button and watch the state change. An .html attachment may open as plain code on a phone, and a screenshot cannot connect to anything.

To send a working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can press Connect and send messages themselves. If you change the code later, the same link shows the new version.

A NOS page cannot fetch from other sites, so share the loopback and copy-paste versions. Your own signaling server would need to run somewhere else.

Questions people ask

Why is WebRTC not working in Chrome?

Read the error name or the connectionState first. A missing navigator.mediaDevices points to a page that is not on HTTPS or localhost. A connection that goes from connecting to failed points to a network path that needs STUN or TURN. Silence after createOffer points to signaling that never delivered the offer.

Do I need a server to use WebRTC?

The media and data go straight between the peers, but the peers first have to exchange an offer, an answer and ICE candidates. WebRTC does not say how. A WebSocket server is one option, and a copy-paste exchange works for two people testing. A TURN server is a separate, optional relay.

Do I need STUN or TURN servers?

Two peers that can already reach each other, such as two tabs on one computer, can connect with no ICE servers. Peers behind different routers may need a STUN server to learn their public address, and a TURN server to relay the traffic when a direct path is refused.

Can WebRTC send text and files without a camera?

Yes. An RTCDataChannel carries any data between the peers and does not touch the camera or the microphone, so it needs no camera permission. Create it with createDataChannel and wait for its open event before calling send.

Why does the remote video stay empty?

The track event fired but nothing put the stream into the video element. In the track handler, set video.srcObject to event.streams[0]. Also check that the sender added the track before it created the offer.

Keep reading