DEV Community

Mason K
Mason K

Posted on

Ship a Go Live button: browser WHIP publishing with MediaMTX, no SDKs

TL;DR

We're building a "Go Live" button that broadcasts your camera from a plain browser tab, and a watch page that plays it back with sub-second delay. The stack is WHIP + WHEP against a self-hosted MediaMTX server. Zero vendor SDKs, about 60 lines of JavaScript total.

📦 Code: github.com/USER/whip-go-live (replace before publishing)

What we're building

WHIP (WebRTC-HTTP Ingestion Protocol) became RFC 9725 in March 2025, and it reduces WebRTC broadcast signaling to a single HTTP POST. WHEP is the same idea for playback. Together they mean the browser can publish and play live video with fetch() and RTCPeerConnection, nothing else.

We'll run MediaMTX (a zero-dependency media server that speaks WHIP, WHEP, RTMP, SRT, and LL-HLS) in Docker, write a publisher page, write a viewer page, then point OBS at the same endpoint to prove the "standard" part of the standard.

You need Docker, any local web server, and a webcam. Node isn't required for the pages, but examples assume a current setup (node 24.x if you use one).

1. Start MediaMTX 🐳

docker run --rm -it \
  -p 8889:8889 \
  -p 8189:8189/udp \
  bluenviron/mediamtx
Enter fullscreen mode Exit fullscreen mode

Port 8889 is the WebRTC HTTP endpoint (WHIP and WHEP live here). Port 8189/udp carries the actual media (ICE). You should see startup logs like:

INF MediaMTX vX.Y.Z
INF [WebRTC] listener opened on :8889 (HTTP), :8189 (ICE/UDP)
Enter fullscreen mode Exit fullscreen mode

Every stream path on the server automatically gets two endpoints:

Purpose URL
Publish (WHIP) http://localhost:8889/mystream/whip
Play (WHEP) http://localhost:8889/mystream/whep

No pre-registration needed; publishing to a path creates it.

2. The publisher: a Go Live button in ~35 lines

<!-- publish.html -->
<video id="preview" autoplay muted playsinline></video>
<button id="go">Go Live</button>

<script>
const WHIP_URL = "http://localhost:8889/mystream/whip";
let pc, sessionUrl;

async function goLive() {
  const stream = await navigator.mediaDevices.getUserMedia({
    video: { width: 1280, height: 720 },
    audio: true
  });
  document.getElementById("preview").srcObject = stream;

  pc = new RTCPeerConnection();
  stream.getTracks().forEach(t => pc.addTrack(t, stream));

  await pc.setLocalDescription(await pc.createOffer());
  await waitForIceGathering(pc);

  const res = await fetch(WHIP_URL, {
    method: "POST",
    headers: { "Content-Type": "application/sdp" },
    body: pc.localDescription.sdp
  });
  if (res.status !== 201) throw new Error(`WHIP publish failed: ${res.status}`);

  sessionUrl = new URL(res.headers.get("Location"), WHIP_URL).href;
  await pc.setRemoteDescription({ type: "answer", sdp: await res.text() });
  console.log("live!");
}

function waitForIceGathering(pc) {
  return new Promise(resolve => {
    if (pc.iceGatheringState === "complete") return resolve();
    pc.addEventListener("icegatheringstatechange", () =>
      pc.iceGatheringState === "complete" && resolve());
  });
}

async function stopLive() {
  if (sessionUrl) await fetch(sessionUrl, { method: "DELETE" });
  pc?.close();
}

document.getElementById("go").onclick = goLive;
addEventListener("beforeunload", stopLive);
</script>
Enter fullscreen mode Exit fullscreen mode

The protocol is all visible right there: POST your SDP offer, get a 201 Created with the answer in the body, and keep the Location header. That URL is your session; DELETE it to hang up cleanly.

💡 Tip: we wait for ICE gathering to finish before POSTing so the offer contains all candidates. WHIP also supports trickle ICE via PATCH, but the wait-then-send version is simpler and fine on a LAN.

Serve it (browsers only allow camera access from secure contexts, and localhost counts):

npx serve .
# then open http://localhost:3000/publish.html
Enter fullscreen mode Exit fullscreen mode

3. The viewer: WHEP in ~25 lines

<!-- watch.html -->
<video id="player" autoplay playsinline controls></video>

<script>
const WHEP_URL = "http://localhost:8889/mystream/whep";

async function watch() {
  const pc = new RTCPeerConnection();
  pc.addTransceiver("video", { direction: "recvonly" });
  pc.addTransceiver("audio", { direction: "recvonly" });

  pc.ontrack = (e) => {
    document.getElementById("player").srcObject = e.streams[0];
  };

  await pc.setLocalDescription(await pc.createOffer());

  const res = await fetch(WHEP_URL, {
    method: "POST",
    headers: { "Content-Type": "application/sdp" },
    body: pc.localDescription.sdp
  });
  await pc.setRemoteDescription({ type: "answer", sdp: await res.text() });
}

watch();
</script>
Enter fullscreen mode Exit fullscreen mode

Same handshake, opposite direction. Open publish.html in one tab, click Go Live, open watch.html in another. Wave at yourself; the delay should be well under a second on localhost.

MediaMTX also ships a built-in test player at http://localhost:8889/mystream if you want to check the stream without your own page.

4. Prove it's a standard: publish from OBS 🎥

OBS has shipped native WHIP output since version 30. In OBS:

  1. Settings → Stream
  2. Service: WHIP
  3. Server: http://localhost:8889/mystream/whip
  4. Bearer Token: leave empty for our unauthenticated dev server
  5. Start Streaming

Your watch.html page now plays the OBS feed instead. Same endpoint, same viewer code, different publisher. FFmpeg can do this too: version 8.0 added a WHIP muxer, so even a headless box can publish sub-second WebRTC (FFmpeg is on 8.1 these days).

That interchangeability is the whole point. The publisher snippet above works unchanged against any WHIP endpoint, including managed ones like Cloudflare Stream or Amazon IVS Real-Time; you swap the URL and add the bearer token they give you.

5. Things that will bite you 🛠️

⚠️ Note: getUserMedia requires HTTPS everywhere except localhost. The moment you demo this to a teammate over LAN IP, the camera prompt silently never appears. Put a TLS proxy (Caddy makes this a one-liner) in front, or tunnel.

UDP reachability. Media flows over 8189/udp. If viewers are remote and behind restrictive NATs or corporate firewalls, you need STUN (MediaMTX configures a default) and eventually a TURN relay. WHIP standardized signaling, not firewall physics.

Auth before you ship. Our dev server accepts any publisher. In mediamtx.yml, give the publish side credentials and pass them from the browser or as the OBS bearer token. Never expose an open WHIP endpoint to the internet unless you enjoy surprise anime streams on your homepage.

WHEP is not an RFC yet. WHIP is a frozen standard. WHEP is still an Internet-Draft (draft-ietf-wish-whep, currently expired awaiting revisions) even though Cloudflare, LiveKit, MediaMTX, OvenMediaEngine, and others already ship it. In practice the flow above is stable across implementations, but pin your server version and skim changelogs when upgrading.

Autoplay policy. Browsers block un-muted autoplay. Our watch page works because you opened it deliberately, but if playback ever starts without a user gesture, add muted to the <video> tag or start paused. Otherwise play() rejects and you'll spend an hour blaming WHEP for what is actually the autoplay police.

Codec negotiation. WHIP doesn't choose codecs; the SDP exchange does. Browsers publish H.264 or VP8 by default and effectively every viewer can decode those. If you experiment with AV1 publishing, verify what your audience's devices can decode before committing a product to it.

Scale is a different article. WebRTC playback holds per-viewer state server-side. For a handful of viewers, one MediaMTX instance is plenty. For thousands, the standard move is hybrid: WHIP in, LL-HLS out for the crowd. MediaMTX already serves LL-HLS of the same stream at http://localhost:8888/mystream with zero extra config, so you can compare both outputs right now:

Output URL Rough delay
WHEP (WebRTC) :8889/mystream/whep sub-second
LL-HLS :8888/mystream a few seconds

What's next

Two follow-ups worth your time. First, wire real auth: JWT-gated WHIP publish plus tokenized playback, which turns this demo into something you could put behind a product's Go Live button. Second, try the same publisher code against a managed WHIP endpoint and notice how little changes; that's the RFC doing its job.

The era of "install this 20 MB SDK to stream from a browser" is ending. It's fetch() and a peer connection now.

Top comments (0)