APIs & Devices NotesRohit’s interview study guide
Chapter 12

Video Conferencing Basics

What happens in a video call, from the first SIP or WebRTC message to the packets on the wire: signalling and media, SDP, ICE, STUN and TURN, room systems and their provider modes, and the statistics a device exposes when a call goes wrong.

4 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#How a video call works

Added

Every video call, whatever product it runs in, is two conversations happening at once. The first is signalling: the endpoints find each other, say who is calling whom, agree which codecs, resolutions and encryption to use, and later agree to hang up. The second is media: a continuous stream of small packets carrying compressed audio, video and shared content, tens to hundreds of them every second, usually over UDP. Signalling is a handful of messages per call and it must be reliable; media is thousands of packets per minute and it must be fast, because a packet that arrives late is as useless as one that never arrives.

A good mental model is booking a table versus eating the meal. Signalling is the phone call to the restaurant: who you are, how many people, what time, any allergies, and later "we'll pay now". It can go through a receptionist, it can be slow, it can be repeated. Media is the meal itself: it happens directly at the table, continuously, and nobody routes your food back through the receptionist. Most of the confusion in video troubleshooting ("the call connected but there's no video", "it works in the office but not from home") comes from forgetting that these are two separate paths that can fail separately.

The two halves are joined by one document: the session description, written in SDP (Session Description Protocol, RFC 8866, 2021). The caller's SDP is an offer ("I can send and receive audio in these codecs, video in these, send it to this address and port"); the other side replies with an answer that picks from it. That offer/answer exchange (RFC 3264, 2002) is the same idea in SIP and in WebRTC; what differs is how the SDP is carried and how the endpoints find a network path.

SIP proxy / registrar or H.323 gatekeeper Room A 198.51.100.10 caller Room B 203.0.113.20 callee INVITE + SDP offer 200 OK + SDP answer signalling: SIP over TLS/TCP/UDP media: RTP / SRTP over UDP audio · video · content, to the SDP's addresses RTCP rides alongside: loss, jitter, round trip, requests for retransmission or a new keyframe
Signalling (dashed) can go through servers; media (solid) goes wherever the SDP says, usually straight between the endpoints.

The model: one SIP call, end to end

This model runs a whole SIP call between two room systems through one proxy. It builds a real-format SIP INVITE with an SDP offer, lets the callee answer with the codecs it supports, and then traces every message, so you can see which ones the proxy handles and where the media actually goes.

sip-call-flow.mjs
// A SIP call between two room systems through one proxy (no Record-Route).
// Signalling goes through the proxy; the media address comes from the SDP, so RTP goes direct.
const rooms = {
  "room-a": { ip: "198.51.100.10", audio: 49170, video: 51372, codecs: ["G722/8000", "PCMU/8000", "PCMA/8000"] },
  "room-b": { ip: "203.0.113.20", audio: 5004, video: 5006, codecs: ["PCMU/8000", "G722/8000"] },
};
const staticPt = { "PCMU/8000": 0, "PCMA/8000": 8, "G722/8000": 9 }; // RFC 3551 static payload types

const sdp = (name, codecs) => {
  const r = rooms[name];
  return [
    "v=0", `o=${name} 2890844526 2890844526 IN IP4 ${r.ip}`, "s=-", `c=IN IP4 ${r.ip}`, "t=0 0",
    `m=audio ${r.audio} RTP/AVP ${codecs.map((c) => staticPt[c]).join(" ")}`,
    ...codecs.map((c) => `a=rtpmap:${staticPt[c]} ${c}`),
    `m=video ${r.video} RTP/AVP 109`, "a=rtpmap:109 H264/90000", "",
  ].join("\r\n");
};
const parse = (body) => {
  const ip = body.match(/^c=IN IP4 (\S+)/m)[1];
  const audio = body.match(/^m=audio (\d+) \S+ ([\d ]+)/m);
  const names = audio[2].split(" ").map((pt) => body.match(new RegExp(`^a=rtpmap:${pt} (\\S+)`, "m"))[1]);
  return { ip, port: Number(audio[1]), codecs: names };
};

const offer = sdp("room-a", rooms["room-a"].codecs);
const invite = [
  "INVITE sip:room-b@example.com SIP/2.0",
  "Via: SIP/2.0/TLS 198.51.100.10:5061;branch=z9hG4bK776asdhds",
  "Max-Forwards: 70",
  "To: <sip:room-b@example.com>",
  "From: <sip:room-a@example.com>;tag=1928301774",
  "Call-ID: a84b4c76e66710@198.51.100.10",
  "CSeq: 314159 INVITE",
  "Contact: <sip:room-a@198.51.100.10:5061;transport=tls>",
  "Content-Type: application/sdp",
  `Content-Length: ${Buffer.byteLength(offer)}`,
  "", offer,
].join("\r\n");
console.log(invite.split("\r\n").slice(0, 10).join("\n"));
console.log(`(${Buffer.byteLength(offer)}-byte SDP offer follows)\n`);

// The answerer keeps only the codecs it supports, in the offer's order (RFC 3264 lets it pick a subset).
const seenOffer = parse(offer);
const chosen = seenOffer.codecs.filter((c) => rooms["room-b"].codecs.includes(c));
const answer = sdp("room-b", chosen);

const trace = [];
const count = { signalling: 0, viaProxy: 0 };
const send = (from, to, what) => {
  trace.push(`${String(trace.length + 1).padStart(2)}  ${from.padEnd(6)} → ${to.padEnd(6)} ${what}`);
  if (!what.startsWith("RTP")) count.signalling++;
  if (from === "proxy" || to === "proxy") count.viaProxy++;
};
const fmt = (s) => { const p = parse(s); return `${p.ip}:${p.port} ${p.codecs.join(" ")}`; };
send("room-a", "proxy", `INVITE   offer  ${fmt(offer)}`);
send("proxy", "room-a", "100 Trying");
send("proxy", "room-b", "INVITE   (proxy adds its own Via)");
send("room-b", "proxy", "180 Ringing");
send("proxy", "room-a", "180 Ringing");
send("room-b", "proxy", `200 OK   answer ${fmt(answer)}`);
send("proxy", "room-a", "200 OK");
send("room-a", "room-b", "ACK      sent to the Contact in the 200 OK (no Record-Route)");
const a = parse(offer), b = parse(answer);
send("room-a", "room-b", `RTP      ${a.ip}:${a.port} → ${b.ip}:${b.port} audio ${b.codecs[0]}`);
send("room-b", "room-a", `RTP      ${b.ip}:${b.port} → ${a.ip}:${a.port} audio ${b.codecs[0]}`);
send("room-a", "room-b", "BYE      (in-dialog request, also direct)");
send("room-b", "room-a", "200 OK");
console.log(trace.join("\n"));
console.log(`\nsignalling messages: ${count.signalling}, through the proxy: ${count.viaProxy}; RTP through the proxy: 0`);
Result
INVITE sip:room-b@example.com SIP/2.0
Via: SIP/2.0/TLS 198.51.100.10:5061;branch=z9hG4bK776asdhds
Max-Forwards: 70
To: <sip:room-b@example.com>
From: <sip:room-a@example.com>;tag=1928301774
Call-ID: a84b4c76e66710@198.51.100.10
CSeq: 314159 INVITE
Contact: <sip:room-a@198.51.100.10:5061;transport=tls>
Content-Type: application/sdp
Content-Length: 241
(241-byte SDP offer follows)

 1  room-a → proxy  INVITE   offer  198.51.100.10:49170 G722/8000 PCMU/8000 PCMA/8000
 2  proxy  → room-a 100 Trying
 3  proxy  → room-b INVITE   (proxy adds its own Via)
 4  room-b → proxy  180 Ringing
 5  proxy  → room-a 180 Ringing
 6  room-b → proxy  200 OK   answer 203.0.113.20:5004 G722/8000 PCMU/8000
 7  proxy  → room-a 200 OK
 8  room-a → room-b ACK      sent to the Contact in the 200 OK (no Record-Route)
 9  room-a → room-b RTP      198.51.100.10:49170 → 203.0.113.20:5004 audio G722/8000
10  room-b → room-a RTP      203.0.113.20:5004 → 198.51.100.10:49170 audio G722/8000
11  room-a → room-b BYE      (in-dialog request, also direct)
12  room-b → room-a 200 OK

signalling messages: 10, through the proxy: 7; RTP through the proxy: 0
What’s happening
  1. The INVITE is a text message, HTTP-like by design (RFC 3261, 2002): a request line, then headers. Via records the path so responses can retrace it; From and To name the parties by SIP URI; Call-ID plus the From tag identify the dialogue; CSeq orders requests; Contact says where Room A can be reached directly. Content-Length: 241 is the size in bytes of the SDP body that follows the blank line.
  2. The SDP offer (built by sdp()) says "send audio to 198.51.100.10:49170 in payload types 9 0 8" — G.722, PCMU (G.711 µ-law) and PCMA (G.711 A-law), whose numbers are fixed by RFC 3551 — "and video to port 51372 as H.264". Order is preference: G.722 first.
  3. The proxy replies 100 Trying at once (so Room A stops retransmitting) and forwards the INVITE, adding its own Via. Room B rings (180) and answers 200 OK with its own SDP: it kept only the codecs it supports, in the offer's order, so the answer says G.722 then PCMU at 203.0.113.20:5004.
  4. Message 8 is the turning point: the ACK goes straight to Room B, to the Contact from the 200 OK, because this proxy didn't add a Record-Route header asking to stay in the path. A proxy that needs to see the whole dialogue (for billing or policy) adds Record-Route, and then ACK and BYE go through it too.
  5. Messages 9 and 10 are the media: RTP from one SDP address to the other, never via the proxy. The last line counts it: 10 signalling messages, 7 of them through the proxy, and no media. That's the design: a proxy can serve thousands of rooms because it only handles a few messages per call.
  6. If you changed Room B's codec list to ["PCMA/8000"], the answer would list only PCMA and the call would use it. If Room B supported none of the offered codecs, it would reject the audio stream (port 0 in the answer) or the whole call with 488 Not Acceptable Here.
AdvancedCodecs: what gets squeezed into the packets

A codec (coder-decoder) compresses captured audio or video into a bitstream and expands it again at the far end. It's where most of the bandwidth decisions live: raw 1080p video at 30 frames per second is about 750 Mbit/s (1920 × 1080 pixels × 12 bits per pixel in 4:2:0 colour × 30 frames), and a conferencing codec squeezes that into 1–4 Mbit/s by sending mostly the differences between frames.

KindCodecWhere you meet it
AudioG.711 (PCMU/PCMA), 64 kbit/s, narrowbandthe telephone network; every SIP device supports it as the fallback
AudioG.722, 64 kbit/s, widebandSIP desk phones and room systems; Chrome still offers it
AudioOpus (RFC 6716, 2012), 6–510 kbit/sWebRTC's mandatory codec (with G.711, RFC 7874); adapts its bitrate and has built-in loss protection
AudioSiren family (Siren 7/14/22, LPR variants)Polycom/Poly systems: the Poly REST API's sample statistics show "SirenLPR"
VideoH.264 / AVCeverywhere; WebRTC's other mandatory video codec (with VP8, RFC 7742)
VideoH.265 / HEVChigher efficiency; Poly systems use it only in point-to-point SIP calls between Poly systems
VideoVP8, VP9, AV1browsers; AV1 gives the best compression at the highest CPU cost

Chrome 156's own offer, captured for the next topic, lists Opus, RED, G.722, PCMU, PCMA, comfort noise and DTMF for audio, and VP8, several H.264 profiles, AV1, VP9 and H.265 for video. Poly's Video Mode Administrator Guide 4.7.0 (HP, July 2025) says its systems support H.264 in both H.323 and SIP calls and H.265 only "during point-to-point SIP calls" with other Poly systems; the guides I read don't list the audio codecs in one place.

AdvancedSIP, H.323 and WebRTC

There are three families of signalling you'll hear about on a video device team. Two are old, standards-based and built for interoperable hardware; one is built into every browser.

SIPH.323WebRTC
FromIETF, RFC 3261 (2002)ITU-T recommendation family (first version 1996)W3C API + IETF protocols (overview: RFC 8825, 2021)
Messagestext, HTTP-like (INVITE, 200 OK, ACK, BYE)binary ASN.1: H.225 call signalling and RAS, H.245 capability exchangenot specified: the app sends SDP any way it likes (WebSocket, HTTPS)
CapabilitiesSDP offer/answer in the message bodyH.245 capability setsSDP offer/answer (JSEP, RFC 9429, 2024)
Directory / routingregistrar and proxies; addresses like sip:room-b@example.comgatekeeper (registration, admission, E.164 aliases)whatever the application builds
MediaRTP/RTCP; SRTP when encryptedRTP/RTCP; H.235 for securityalways SRTP, keyed by DTLS; ICE always on
NAT handlingsession border controllers, ICE optionalH.460.18/.19 firewall traversalICE with STUN and TURN, built in
Typical homedesk phones, room systems, PBXs, service providersolder room systems and MCUs, still in large enterprisesbrowsers, web apps, many cloud meeting services' web clients

For a Poly room system in Poly Video mode (its own conferencing app, as opposed to running Teams or Zoom), the admin guide documents both SIP and H.323 under Call Configuration: SIP with a registrar and proxy over TLS, TCP or UDP ("Auto" tries them in that order when a proxy server is used, or only TCP then UDP on an unregistered system, and Poly recommends TLS), and H.323 with a gatekeeper, an H.323 name and an E.164 extension, with optional H.235 Annex D authentication. Encryption is AES: for SIP the SRTP suites AES_CM_128_HMAC_SHA1_80/_32 and their 256-bit versions (RFCs 3711, 4568, 6188); for H.323, AES-CBC per H.235.6. When a room system has to reach a cloud service from behind a firewall, the guide describes H.460.18/H.460.19 traversal through a device such as Poly Clariti Edge, and lists the ports a strict firewall must allow out: 1720 TCP, 14085–15084 TCP, 1719 UDP and 16386–25386 UDP.

AdvancedWhat a packet costs on the wire

A codec's bitrate is only the payload. Every RTP packet also carries an RTP header, a UDP header and an IP header, and with SRTP an authentication tag. At 50 packets a second those bytes add up, which is why "64 kbit/s audio" never fits in a 64 kbit/s link.

packet-overhead.mjs
// What a media stream really costs on the network: payload + RTP + UDP + IPv4 (+ SRTP's auth tag).
const HEADERS = { ipv4: 20, udp: 8, rtp: 12, srtpTag80: 10 }; // bytes; tag of AES_CM_128_HMAC_SHA1_80
const perPacket = HEADERS.ipv4 + HEADERS.udp + HEADERS.rtp + HEADERS.srtpTag80;

function audio(name, payloadKbps, ptimeMs) {
  const packetsPerSec = 1000 / ptimeMs;
  const payloadBytes = (payloadKbps * 1000 / 8) * (ptimeMs / 1000);
  const wireKbps = ((payloadBytes + perPacket) * 8 * packetsPerSec) / 1000;
  return { name, payloadKbps, packetsPerSec, payloadBytes, wireKbps };
}
function video(name, payloadKbps, fps, maxPayload = 1200) {
  const frameBytes = (payloadKbps * 1000 / 8) / fps;
  const packetsPerFrame = Math.ceil(frameBytes / maxPayload);
  const packetsPerSec = packetsPerFrame * fps;
  const wireKbps = (payloadKbps * 1000 + perPacket * 8 * packetsPerSec) / 1000;
  return { name, payloadKbps, packetsPerSec, payloadBytes: Math.round(frameBytes / packetsPerFrame), wireKbps };
}

const rows = [
  audio("G.711 µ-law 64k, 20 ms", 64, 20),
  audio("G.722 64k, 20 ms", 64, 20),
  audio("Opus 32k, 20 ms", 32, 20),
  audio("Opus 32k, 10 ms", 32, 10),
  video("H.264 2,000k, 30 fps", 2000, 30),
];
console.log(`headers per packet: ${perPacket} bytes (IPv4 20 + UDP 8 + RTP 12 + SRTP tag 10)\n`);
console.log("stream                  pkts/s  payload B  on the wire  overhead");
for (const r of rows) {
  const overhead = ((r.wireKbps - r.payloadKbps) / r.wireKbps) * 100;
  console.log(
    `${r.name.padEnd(23)} ${String(r.packetsPerSec).padStart(6)}  ${String(r.payloadBytes).padStart(9)}  ` +
      `${r.wireKbps.toFixed(1).padStart(7)} kbps  ${overhead.toFixed(0).padStart(6)}%`,
  );
}
Result
headers per packet: 50 bytes (IPv4 20 + UDP 8 + RTP 12 + SRTP tag 10)

stream                  pkts/s  payload B  on the wire  overhead
G.711 µ-law 64k, 20 ms      50        160     84.0 kbps      24%
G.722 64k, 20 ms            50        160     84.0 kbps      24%
Opus 32k, 20 ms             50         80     52.0 kbps      38%
Opus 32k, 10 ms            100         40     72.0 kbps      56%
H.264 2,000k, 30 fps       210       1190   2084.0 kbps       4%
What’s happening
  1. Every packet pays a fixed 50 bytes: IPv4 (20), UDP (8), RTP (12) and the 10-byte authentication tag of AES_CM_128_HMAC_SHA1_80, the SRTP suite Chrome negotiated in the loopback run in the next topic. Ethernet or Wi-Fi framing comes on top of this; the model stops at the IP layer.
  2. G.711 with 20 ms packets sends 50 packets a second of 160 bytes (64,000 bits ÷ 8 × 0.02 s). Add 50 bytes each and it's 84.0 kbit/s on the wire: almost a quarter of the traffic is headers. G.722 has the same bitrate and the same overhead.
  3. Opus at 32 kbit/s (the targetBitrate Chrome reported for its audio) sends only 80-byte payloads, so the same 50 bytes of headers are now 38% of the stream. Halving the packet time to 10 ms (Chrome's offer allows minptime=10) doubles the packet rate and pushes the overhead to 56%: lower latency, much more bandwidth.
  4. Video is the opposite: a 2 Mbit/s H.264 stream at 30 fps produces about 8.3 KB per frame, split into 7 packets of up to 1,200 bytes (kept under the path's MTU), 210 packets a second. Headers add only 4%, so for video you budget the codec rate plus a few percent.
  5. This is the arithmetic behind every vendor bandwidth table and every QoS policy: audio is small but packet-heavy, video is large but header-light, and both count packets per second, which is what routers and Wi-Fi actually struggle with.
AdvancedInterop with Teams: Cloud Video Interop

Interop: when the room system and the meeting speak different languages

A SIP/H.323 room system can't join a Teams meeting by itself: Teams uses its own signalling. Microsoft's answer is Cloud Video Interop (CVI): a certified partner runs a gateway that joins the Teams meeting on one side and talks SIP/H.323 to the room on the other. Microsoft Learn's CVI page (updated September 2026) lists three certified partners: Pexip Infinity, Cisco Webex Video Integration, and Poly CloudConnect powered by Pexip. Microsoft describes CVI as an intermediate solution while you move to native Teams Rooms devices, which is exactly what a room system in Teams Rooms provider mode is (see Room systems and provider modes).

AdvancedGotcha: NAT breaks the SDP's media address
Who sends the ACK?

In the model, why does message 8 (ACK) go straight from Room A to Room B instead of through the proxy, and what single header would change that?

Show answer

Because the proxy didn't add Record-Route. Adding it keeps the proxy in every later request of the dialogue.

What’s happening
  1. A SIP dialogue is set up by the INVITE transaction; requests inside the dialogue (ACK for a 2xx, BYE, re-INVITE) are sent according to the dialogue's route set.
  2. The route set is built from Record-Route headers that proxies insert while forwarding the INVITE. The model's proxy inserted none, so the route set is empty.
  3. With an empty route set, Room A sends in-dialogue requests to the remote target: the URI in Room B's Contact header from the 200 OK. That's why the ACK and later the BYE go direct.
  4. If the proxy adds Record-Route: <sip:proxy.example.com;lr>, both rooms put it in their route sets and every later request goes through the proxy. Media still doesn't: RTP follows the SDP, not the route set.

#WebRTC concepts

Added

WebRTC is the set of browser APIs (W3C) and network protocols (IETF) that let a web page send and receive real-time audio, video and data directly to another endpoint. It's the reason a meeting service can run in a browser tab with no plug-in. Its centre is one object, RTCPeerConnection, which owns everything below the API: codecs, encryption, congestion control, and the job of finding a network path through NATs and firewalls.

What WebRTC deliberately doesn't include is signalling. The browser will produce an SDP offer for you and accept an answer, but how those blobs travel between the two peers (a WebSocket, an HTTPS POST, a message queue) is your application's job. The IETF wrote that division down as JSEP, the JavaScript Session Establishment Protocol (RFC 9429, April 2024, replacing RFC 8829).

The mental model: two people exchanging business cards through a courier, then trying every number on the card. The SDP offer and answer are the cards: codecs, encryption fingerprints, and a list of addresses ("candidates") where each side might be reachable. Your signalling server is the courier: it doesn't care what's on the card. Then ICE (Interactive Connectivity Establishment, RFC 8445, 2018) has both sides try the addresses in pairs, best first, until one works. STUN (RFC 8489, 2020) is how a peer learns the public address its NAT gave it; TURN (RFC 8656, 2020) is a relay to fall back on when no direct path works.

Signalling server yours: WebSocket / HTTPS Peer A RTCPeerConnection Peer B RTCPeerConnection offer · answer · candidates NAT A NAT B direct path (host / srflx), preferred DTLS-SRTP media and SCTP data STUN server "what's my public address?" TURN relay fallback, costs bandwidth relayed path: used only when no direct pair passes its checks
Signalling (dashed) carries the SDP; ICE then tests candidate pairs and keeps the best path that works.

The sequence is always the same. The caller calls createOffer() and setLocalDescription(offer), which starts gathering candidates; it sends the offer through signalling. The callee calls setRemoteDescription(offer), createAnswer(), setLocalDescription(answer) and sends the answer back; the caller calls setRemoteDescription(answer). Meanwhile each side's icecandidate events are sent across and fed to addIceCandidate() (trickle ICE, RFC 8838, 2021), so checks start before gathering is finished. When a candidate pair passes, DTLS runs over it to derive the SRTP keys (RFC 5764), and media flows.

The model: reading a real SDP offer

An SDP offer from a browser looks intimidating: Chrome 156's, for one audio and one video track, was 179 lines. This model takes a real one, captured from headless Chrome 156 on this machine on 2026-10-27 (the audio section exactly as produced; the video section trimmed from 27 payload types to VP8 and one H.264 profile), and explains what each part is for.

sdp-explain.mjs
// Reads a real SDP offer from Chrome 156 (two RTCPeerConnections in one page; video section trimmed)
// and explains what each part of it is for.
const offer = `
v=0
o=- 3405956684259818487 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
a=extmap-allow-mixed
a=msid-semantic: WMS 8294ec7a-6a8f-4c78-8d14-de86154b55bc
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:F4/9
a=ice-pwd:Cq+J25Ja8RefvBry+dVM0EmA
a=ice-options:trickle
a=fingerprint:sha-256 98:31:17:88:26:0D:41:19:62:41:A9:1B:57:FB:55:3E:4D:B2:BE:16:1D:BC:E3:DD:75:0B:AC:04:27:44:4B:81
a=setup:actpass
a=mid:0
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid
a=sendrecv
a=msid:8294ec7a-6a8f-4c78-8d14-de86154b55bc 4e9dd1ba-6a0b-42e0-889b-7f082a1f7f82
a=rtcp-mux
a=rtcp-rsize
a=rtcp-xr:rcvr-rtt=all
a=rtpmap:111 opus/48000/2
a=rtcp-fb:111 transport-cc
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:63 red/48000/2
a=fmtp:63 111/111
a=rtpmap:9 G722/8000
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:13 CN/8000
a=rtpmap:110 telephone-event/48000
a=rtpmap:126 telephone-event/8000
a=ssrc:3223377670 cname:MC19id6WnJmlRnUd
a=ssrc:3223377670 msid:8294ec7a-6a8f-4c78-8d14-de86154b55bc 4e9dd1ba-6a0b-42e0-889b-7f082a1f7f82
m=video 9 UDP/TLS/RTP/SAVPF 96 97 102 103
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:F4/9
a=ice-pwd:Cq+J25Ja8RefvBry+dVM0EmA
a=ice-options:trickle
a=fingerprint:sha-256 98:31:17:88:26:0D:41:19:62:41:A9:1B:57:FB:55:3E:4D:B2:BE:16:1D:BC:E3:DD:75:0B:AC:04:27:44:4B:81
a=setup:actpass
a=mid:1
a=sendrecv
a=rtcp-mux
a=rtcp-rsize
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtcp-fb:96 ccm fir
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
a=rtpmap:102 H264/90000
a=fmtp:102 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f
a=rtpmap:103 rtx/90000
a=fmtp:103 apt=102
a=ssrc-group:FID 3551622550 3440678679
`;

const sections = [{ kind: "session", lines: [] }];
for (const line of offer.trim().split("\n")) {
  if (line.startsWith("m=")) sections.push({ kind: line.slice(2, line.indexOf(" ")), lines: [] });
  sections.at(-1).lines.push(line);
}
const attr = (s, name) => s.lines.filter((l) => l.startsWith(`a=${name}`)).map((l) => l.slice(name.length + 2));
const one = (s, name) => attr(s, name)[0];
const h264 = (id) => {
  const [idc, flags, level] = [0, 2, 4].map((i) => parseInt(id.slice(i, i + 2), 16));
  const name = { 66: "Baseline", 77: "Main", 100: "High" }[idc];
  return `${idc === 66 && flags & 0x40 ? "Constrained " : ""}${name}, level ${level / 10}`;
};

const [session, ...media] = sections;
const origin = session.lines.find((l) => l.startsWith("o=")).split(" ");
console.log(`session  id ${origin[1]} version ${origin[2]} · BUNDLE ${one(session, "group:BUNDLE ")} (one transport for all sections)`);
for (const s of media) {
  const [, port, proto, ...pts] = s.lines[0].split(" ");
  const direction = ["sendrecv", "sendonly", "recvonly", "inactive"].find((d) => s.lines.includes(`a=${d}`));
  console.log(`\nm=${s.kind}  mid ${one(s, "mid:")} · ${direction} · ${proto} · port ${port} (placeholder; candidates trickle)`);
  console.log(`  ICE    ufrag ${one(s, "ice-ufrag:")}, ${one(s, "ice-pwd:").length}-char password, options: ${one(s, "ice-options:")}`);
  console.log(`  DTLS   setup ${one(s, "setup:")}, fingerprint ${one(s, "fingerprint:").slice(0, 19)}…`);
  console.log(`  RTCP   ${s.lines.includes("a=rtcp-mux") ? "muxed on the RTP port" : "separate port"}${s.lines.includes("a=rtcp-rsize") ? ", reduced-size" : ""}`);
  for (const pt of pts) {
    const codec = one(s, `rtpmap:${pt} `);
    const fmtp = one(s, `fmtp:${pt} `) ?? "";
    const fb = attr(s, `rtcp-fb:${pt} `);
    let note = "";
    if (codec.startsWith("rtx")) note = `retransmissions for ${fmtp.split("=")[1]}`;
    else if (codec.startsWith("red")) note = `redundant copies of ${fmtp.split("/")[0]}`;
    else if (codec.startsWith("CN")) note = "comfort noise";
    else if (codec.startsWith("telephone-event")) note = "DTMF key presses";
    else if (codec.startsWith("H264")) note = `${h264(fmtp.match(/profile-level-id=(\w+)/)[1])}, packetization-mode ${fmtp.match(/packetization-mode=(\d)/)[1]}`;
    else if (fmtp) note = fmtp;
    if (fb.length) note += `${note ? "; " : ""}feedback: ${fb.join(", ")}`;
    console.log(`  ${pt === pts[0] ? "codecs" : "      "} ${pt.padStart(3)} ${codec.padEnd(22)} ${note}`);
  }
  const fid = one(s, "ssrc-group:FID ");
  if (fid) console.log(`  SSRC   ${fid.split(" ")[0]} media + ${fid.split(" ")[1]} its rtx stream`);
}
Result
session  id 3405956684259818487 version 2 · BUNDLE 0 1 (one transport for all sections)

m=audio  mid 0 · sendrecv · UDP/TLS/RTP/SAVPF · port 9 (placeholder; candidates trickle)
  ICE    ufrag F4/9, 24-char password, options: trickle
  DTLS   setup actpass, fingerprint sha-256 98:31:17:88…
  RTCP   muxed on the RTP port, reduced-size
  codecs 111 opus/48000/2           minptime=10;useinbandfec=1; feedback: transport-cc
          63 red/48000/2            redundant copies of 111
           9 G722/8000              
           0 PCMU/8000              
           8 PCMA/8000              
          13 CN/8000                comfort noise
         110 telephone-event/48000  DTMF key presses
         126 telephone-event/8000   DTMF key presses

m=video  mid 1 · sendrecv · UDP/TLS/RTP/SAVPF · port 9 (placeholder; candidates trickle)
  ICE    ufrag F4/9, 24-char password, options: trickle
  DTLS   setup actpass, fingerprint sha-256 98:31:17:88…
  RTCP   muxed on the RTP port, reduced-size
  codecs  96 VP8/90000              feedback: goog-remb, transport-cc, ccm fir, nack, nack pli
          97 rtx/90000              retransmissions for 96
         102 H264/90000             Baseline, level 3.1, packetization-mode 1
         103 rtx/90000              retransmissions for 102
  SSRC   3551622550 media + 3440678679 its rtx stream
What’s happening
  1. The session part (before the first m=) is short: o= holds a random session id and a version (2) that goes up on every renegotiation, and a=group:BUNDLE 0 1 says both media sections will share one transport, one ICE connection and one port, instead of one each (RFC 9143, 2022). That's why a WebRTC call needs one path through the firewall, not four.
  2. Each m= line starts a media section, identified by a=mid. Its port is 9, a placeholder: with a=ice-options:trickle the real addresses arrive later as candidates. The protocol UDP/TLS/RTP/SAVPF spells out the stack: RTP over DTLS-secured UDP, with Secure (SRTP) AVP profile and RTCP Feedback. sendrecv means this side will both send and receive.
  3. ice-ufrag and ice-pwd are the credentials ICE uses to authenticate its connectivity checks (STUN messages). The DTLS fingerprint is the hash of the browser's self-signed certificate: the far end checks the certificate it sees in the DTLS handshake against this value, which is how WebRTC ties the encryption keys to the signalling. setup:actpass means "I can be either the DTLS client or server; you choose".
  4. The audio codec list is in preference order: Opus (111), with useinbandfec=1 (Opus's built-in loss recovery) and minptime=10; then red (63), which sends redundant copies of earlier Opus frames; then G.722, PCMU and PCMA for interop with phones and SIP systems; CN (comfort noise) and telephone-event (DTMF, the keypad tones you press to enter a meeting PIN).
  5. In video, every real codec has a companion rtx payload type (97 for 96, 103 for 102): retransmissions are sent on a separate stream so the receiver can tell repairs from originals. The a=ssrc-group:FID line pairs the two SSRCs (stream ids). The feedback list says what the receiver may ask for: nack (resend these packets), nack pli (picture lost: send a keyframe), ccm fir (full intra request), plus two bandwidth-estimation mechanisms (goog-remb, transport-cc).
  6. profile-level-id=42001f decodes as H.264 Baseline profile, level 3.1 (up to 1280 × 720 at 30 fps); Chrome also offered 42e01f, the Constrained Baseline profile WebRTC requires. This is the part that matters when a browser has to talk to a hardware endpoint: the two must share an H.264 profile and packetisation mode, or the answerer drops H.264 and you get a different codec, or no video.
AdvancedWhat happened in a real browser

To see the API do all of this, I ran two peer connections in one page of headless Chrome 156 on 2026-10-27 (through the DevTools protocol; no STUN or TURN server, so only host candidates). This is the code that ran; the "signalling channel" is just function calls between the two objects.

loopback.js (runs in the page)
// Runs in a browser page: two peer connections, with the "signalling channel" being plain function calls.
const caller = new RTCPeerConnection();   // no iceServers: host candidates only
const callee = new RTCPeerConnection();
const log = [];
caller.onicecandidate = (e) => e.candidate && callee.addIceCandidate(e.candidate); // trickle ICE
callee.onicecandidate = (e) => e.candidate && caller.addIceCandidate(e.candidate);
caller.onsignalingstatechange = () => log.push(`caller signaling: ${caller.signalingState}`);
caller.onconnectionstatechange = () => log.push(`caller connection: ${caller.connectionState}`);
callee.ontrack = (e) => log.push(`callee got a ${e.track.kind} track`);

// Media without a camera: a canvas animation and an oscillator.
const canvas = Object.assign(document.createElement("canvas"), { width: 640, height: 360 });
const g = canvas.getContext("2d");
let frame = 0;
const paint = setInterval(() => { g.fillStyle = `hsl(${frame++ % 360} 70% 50%)`; g.fillRect(0, 0, 640, 360); }, 33);
const audioCtx = new AudioContext();
const tone = audioCtx.createOscillator();
const sink = audioCtx.createMediaStreamDestination();
tone.connect(sink);
tone.start();
const stream = new MediaStream([sink.stream.getAudioTracks()[0], canvas.captureStream(30).getVideoTracks()[0]]);
for (const track of stream.getTracks()) caller.addTrack(track, stream);

const offer = await caller.createOffer();
await caller.setLocalDescription(offer);   // caller: have-local-offer, starts gathering candidates
await callee.setRemoteDescription(offer);
const answer = await callee.createAnswer();
await callee.setLocalDescription(answer);
await caller.setRemoteDescription(answer); // caller: stable

while (caller.connectionState !== "connected") await new Promise((r) => setTimeout(r, 50));
await new Promise((r) => setTimeout(r, 5000)); // let media flow for 5 s
clearInterval(paint);

const pick = (report, type, kind) => [...report.values()].find((s) => s.type === type && (!kind || s.kind === kind));
const sent = await caller.getStats();
const got = await callee.getStats();
const pair = [...sent.values()].find((s) => s.type === "candidate-pair" && s.nominated);
const transport = pick(sent, "transport");
return {
  log,
  answerDirections: answer.sdp.match(/^a=(sendrecv|recvonly|sendonly|inactive)/gm),
  selectedPair: `${sent.get(pair.localCandidateId).candidateType} → ${sent.get(pair.remoteCandidateId).candidateType}, rtt ${pair.currentRoundTripTime} s`,
  dtls: `${transport.dtlsState}, SRTP ${transport.srtpCipher}`,
  audioOut: pick(sent, "outbound-rtp", "audio"),
  videoOut: pick(sent, "outbound-rtp", "video"),
  audioIn: pick(got, "inbound-rtp", "audio"),
  videoIn: pick(got, "inbound-rtp", "video"),
};
Observed in headless Chrome 156 (one run, 2026-10-27; selected getStats fields)
caller signaling: have-local-offer
callee got a audio track
callee got a video track
caller signaling: stable
caller connection: connecting
caller connection: connected
answer directions: a=recvonly, a=recvonly
selected pair: host → host, rtt 0.001 s
transport: connected, SRTP SRTP_AES128_CM_HMAC_SHA1_80
caller audio out: packetsSent 252, bytesSent 20245, targetBitrate 32000
caller video out: packetsSent 180, bytesSent 72810, framesEncoded 150, 640x360 at 30 fps, targetBitrate 1700000
callee video in:  packetsReceived 180, packetsLost 0, jitter 0, framesDecoded 150, freezeCount 0, jitterBufferDelay 1.163178 s over 150 frames
What’s happening
  1. The signalling states follow JSEP exactly: setLocalDescription(offer) puts the caller in have-local-offer; setRemoteDescription(answer) brings it back to stable. The callee fires track for audio and video as soon as it applies the offer, before any media flows: tracks exist from negotiation, packets come later.
  2. Both media sections in the answer are recvonly. The callee added no tracks of its own, so it can only receive: that is the answerer rule from the offer/answer model below, produced by a real browser.
  3. ICE picked a host → host pair with a 1 ms round trip: both peers are on one machine. One detail needed a flag: headless Chrome hides host addresses behind random .local mDNS names (a privacy feature), and in my first run those names never resolved, so ICE never left new. Re-running with --disable-features=WebRtcHideLocalIpsWithMdns exposed the real address and the connection came up. A second flag (--autoplay-policy=no-user-gesture-required) let the AudioContext start without a click; without it the audio track sent 0 packets.
  4. The transport reports DTLS connected and the SRTP suite SRTP_AES128_CM_HMAC_SHA1_80, so the media was encrypted even though both peers lived in one page. WebRTC has no unencrypted mode.
  5. The audio numbers check the arithmetic of the overhead model: 252 packets in about 5 s is 50 packets a second (20 ms Opus frames), and 20,245 bytes ÷ 252 ≈ 80 bytes of payload each, which is 32 kbit/s, matching targetBitrate 32000. (bytesSent counts payload only, not headers.)
  6. Video shows target versus actual: the encoder was allowed 1.7 Mbit/s but sent 72,810 bytes in about 5 s, roughly 116 kbit/s, because a flat colour fill compresses to almost nothing. A real camera picture would use far more. The callee's jitter buffer held each frame for about 7.8 ms on average (1.163 s ÷ 150 frames). These are one run's numbers; timings will differ from run to run.

I also checked what happens when policy forbids direct paths but there's no relay. With new RTCPeerConnection({ iceTransportPolicy: "relay" }) on both sides and no iceServers, Chrome 156 finished gathering with no candidates at all (icegatheringstatechange went gathering → complete, the only icecandidate event was the end-of-candidates null), and after 8 seconds iceConnectionState was still new. That's what a misconfigured TURN setup looks like from the API: not an error, just silence.

AdvancedOffer/answer: what the answerer is allowed to say

The offer/answer rules come from RFC 3264 and still apply in WebRTC. The answer has the same media sections in the same order; for each one it can accept a subset of the codecs, change the direction from its point of view, or reject the section with port 0. JSEP adds that an answerer facing setup:actpass takes the DTLS active (client) role.

offer-answer.mjs
// The answerer's side of SDP offer/answer (RFC 3264 rules, JSEP's choices for DTLS roles).
// An offer is a list of media sections; the answer must have the same sections, in the same order.
const offer = [
  { mid: "0", kind: "audio", direction: "sendrecv", setup: "actpass", codecs: ["111 opus/48000/2", "9 G722/8000", "0 PCMU/8000"] },
  { mid: "1", kind: "video", direction: "sendrecv", setup: "actpass", codecs: ["96 VP8/90000", "102 H264/90000", "45 AV1/90000"] },
];

const flip = { sendrecv: "sendrecv", sendonly: "recvonly", recvonly: "sendonly", inactive: "inactive" };
function answer(offer, me) {
  return offer.map((m) => {
    const mine = me.codecs[m.kind] ?? [];
    // Keep the codecs both sides support, with the offer's payload numbers, in the offer's order
    // (RFC 3264 recommends that order; the answerer may reorder if it has a reason).
    const common = m.codecs.filter((c) => mine.includes(c.split(" ")[1]));
    if (!common.length) return { mid: m.mid, kind: m.kind, port: 0, note: "rejected (no common codec)" };
    let direction = flip[m.direction];
    if (!me.sends[m.kind] && direction === "sendrecv") direction = "recvonly"; // nothing to send
    if (!me.sends[m.kind] && direction === "sendonly") direction = "inactive";
    return { mid: m.mid, kind: m.kind, port: 9, direction, setup: m.setup === "actpass" ? "active" : "actpass", codecs: common };
  });
}

const answerers = {
  "room system (G.722, PCMU, H.264; camera on)": {
    codecs: { audio: ["G722/8000", "PCMU/8000"], video: ["H264/90000"] }, sends: { audio: true, video: true } },
  "viewer with no camera (like pc2 in the loopback)": {
    codecs: { audio: ["opus/48000/2"], video: ["VP8/90000", "H264/90000"] }, sends: { audio: false, video: false } },
  "audio-only phone (no common video codec)": {
    codecs: { audio: ["PCMU/8000"], video: [] }, sends: { audio: true, video: false } },
};

for (const [who, me] of Object.entries(answerers)) {
  console.log(who);
  for (const m of answer(offer, me)) {
    if (m.port === 0) console.log(`  m=${m.kind} port 0 → ${m.note}`);
    else console.log(`  m=${m.kind} ${m.direction.padEnd(8)} setup:${m.setup}  ${m.codecs.join(", ")}  → media uses ${m.codecs[0].split(" ")[1]}`);
  }
}
Result
room system (G.722, PCMU, H.264; camera on)
  m=audio sendrecv setup:active  9 G722/8000, 0 PCMU/8000  → media uses G722/8000
  m=video sendrecv setup:active  102 H264/90000  → media uses H264/90000
viewer with no camera (like pc2 in the loopback)
  m=audio recvonly setup:active  111 opus/48000/2  → media uses opus/48000/2
  m=video recvonly setup:active  96 VP8/90000, 102 H264/90000  → media uses VP8/90000
audio-only phone (no common video codec)
  m=audio sendrecv setup:active  0 PCMU/8000  → media uses PCMU/8000
  m=video port 0 → rejected (no common codec)
What’s happening
  1. The offer has two sections (mid 0 and 1), both sendrecv and actpass: the same shape as Chrome's real offer, with fewer codecs.
  2. The room system supports G.722, PCMU and H.264. answer() keeps the offered entries it supports, with the offer's payload numbers and in the offer's order (RFC 3264 recommends keeping that order unless the answerer has a reason), so the answer lists 9 G722/8000, 0 PCMU/8000 and the call uses G.722. Its video becomes H.264 because the room doesn't do VP8 or AV1.
  3. The viewer with no camera has nothing to send, so sendrecv becomes recvonly for both sections: exactly what the real Chrome answer said in the loopback run, where the callee added no tracks.
  4. The audio-only phone shares no video codec, so its answer keeps the video section but with port 0: rejected. The call goes ahead audio-only. Deleting the section instead would be a protocol error: the answer must have the same number of m= sections as the offer.
  5. All answers say setup:active: the answerer becomes the DTLS client and starts the handshake once ICE has a path. If both sides claimed the same role, DTLS would never complete, one of the more confusing ways interop between a browser and a hardware endpoint fails.
AdvancedICE: which path wins

ICE turns "we have several addresses each" into "this is the path". Each side gathers candidates: host (its own interface addresses), srflx (server-reflexive: the public address and port its NAT mapped, learned from a STUN server) and relay (an address allocated on a TURN server). Each candidate gets a priority from a formula in RFC 8445; candidates are paired, pairs are sorted, and connectivity checks (STUN requests carrying the ice-ufrag/ice-pwd credentials) run over them until a pair works and the controlling side nominates it.

ice-pairs.mjs
// ICE in miniature: candidate priorities (RFC 8445 §5.1.2), pair priorities (§6.1.2.3), and which pair
// survives connectivity checks. The NAT model is deliberately simplified (see the walkthrough).
const TYPE_PREF = { host: 126, srflx: 100, relay: 0 }; // RFC 8445's recommended type preferences
const priority = (type, localPref = 65535, component = 1) => 2 ** 24 * TYPE_PREF[type] + 2 ** 8 * localPref + (256 - component);
const pairPriority = (g, d) => 2 ** 32 * Math.min(g, d) + 2 * Math.max(g, d) + (g > d ? 1 : 0);

console.log(`Chrome's host candidate 2113937151 = 2^24×126 + 2^8×30 + 255 → ${priority("host", 30) === 2113937151}\n`);

// Local side: a server-reflexive candidate is sent from its base (the host address), so RFC 8445 pairs
// the base instead and prunes the duplicate. Remote side: everything the peer signalled.
const local = () => ["host", "relay"].map((type) => ({ type, prio: priority(type) }));
const remote = (S) => ["host", ...(S.udpOut ? ["srflx"] : []), "relay"].map((type) => ({ type, prio: priority(type) }));
const reachesTurn = (S) => S.udpOut || S.tls443; // TURN over TLS on 443 gets out of most firewalls

function check(a, b, A, B) {
  if (b.type === "host" && A.lan !== B.lan) return "private address, other network";
  // A relay address is public: the other side only has to send UDP to it; the relay's owner must reach its TURN server.
  const aSends = a.type === "relay" ? reachesTurn(A) : A.udpOut;
  const bSends = b.type === "relay" ? reachesTurn(B) : B.udpOut;
  if (!aSends || !bSends) return "UDP blocked";
  // Reaching the peer's srflx address needs "hole punching": both sides send at once, which only works if a NAT
  // keeps one public mapping for every destination (endpoint-independent mapping, "eim").
  if (b.type === "srflx" && (B.nat !== "eim" || (a.type === "host" && A.nat !== "eim"))) return "symmetric NAT picked a new port";
  return "ok";
}

const scenarios = [
  ["two rooms on one office LAN", { lan: "office", nat: "eim", udpOut: true }, { lan: "office", nat: "eim", udpOut: true }],
  ["office room ↔ home laptop", { lan: "office", nat: "eim", udpOut: true }, { lan: "home", nat: "eim", udpOut: true }],
  ["room behind a symmetric NAT ↔ home", { lan: "office", nat: "symmetric", udpOut: true }, { lan: "home", nat: "eim", udpOut: true }],
  ["hotel Wi-Fi blocks UDP ↔ office room", { lan: "hotel", nat: "eim", udpOut: false, tls443: true }, { lan: "office", nat: "eim", udpOut: true }],
];
for (const [name, A, B] of scenarios) {
  const pairs = local().flatMap((a) => remote(B).map((b) => ({ a, b, prio: pairPriority(a.prio, b.prio) })));
  pairs.sort((x, y) => y.prio - x.prio);
  const tried = [];
  let chosen;
  for (const p of pairs) {
    const result = check(p.a, p.b, A, B);
    tried.push(`${p.a.type}→${p.b.type} ${result === "ok" ? "✓" : `✗ ${result}`}`);
    if (result === "ok") { chosen = p; break; }
  }
  const relayed = chosen.a.type === "relay" || chosen.b.type === "relay";
  console.log(`${name}\n  ${tried.join(" · ")}\n  selected ${chosen.a.type}→${chosen.b.type}${relayed ? ": media flows through a TURN server" : ": direct"}`);
}
Result
Chrome's host candidate 2113937151 = 2^24×126 + 2^8×30 + 255 → true

two rooms on one office LAN
  host→host ✓
  selected host→host: direct
office room ↔ home laptop
  host→host ✗ private address, other network · host→srflx ✓
  selected host→srflx: direct
room behind a symmetric NAT ↔ home
  host→host ✗ private address, other network · host→srflx ✗ symmetric NAT picked a new port · host→relay ✓
  selected host→relay: media flows through a TURN server
hotel Wi-Fi blocks UDP ↔ office room
  host→host ✗ private address, other network · host→srflx ✗ UDP blocked · host→relay ✗ UDP blocked · relay→host ✗ private address, other network · relay→srflx ✓
  selected relay→srflx: media flows through a TURN server
What’s happening
  1. The first line checks the formula against reality: Chrome's host candidate in the loopback run had priority 2113937151, which is exactly 2^24 × 126 (the host type preference) + 2^8 × 30 (Chrome's local preference for that interface) + 255 (256 − component 1). The type preference dominates, so every host candidate outranks every srflx candidate, which outranks every relay.
  2. Pairs are sorted by the pair formula, which is driven by the lower of the two priorities, so host→host is tried first and anything involving a relay last. On the local side, a srflx candidate is sent from its base (the host address), so RFC 8445 pairs the base and prunes the duplicate: that's why the model's local list is just host and relay, and why browsers' stats often show a host → srflx pair for a direct path through NAT.
  3. Two rooms on one LAN connect host→host: lowest latency, no server involved. Office room to home laptop: the private addresses can't reach each other, but host→srflx works because both NATs keep one public mapping for every destination (endpoint-independent mapping), so packets sent by each side open the way for the other's.
  4. A symmetric NAT picks a new public port for each destination, so the srflx address learned from the STUN server is useless for reaching the peer: the check fails, and the first working pair is host→relay. Media now goes through the TURN server, which costs its operator bandwidth for every relayed call.
  5. On hotel Wi-Fi that blocks UDP, nothing direct works. The peer's own relay, reached over TURN on TLS port 443, gets out because it looks like HTTPS traffic; relay→srflx is selected. This is why deployments commonly offer TURN on TLS port 443 as well as on UDP port 3478, the default STUN/TURN port.
  6. The NAT model is deliberately simple (two behaviours, one check per pair, in order). Real agents pace checks every few milliseconds, check in both directions, learn peer-reflexive candidates from incoming checks, and keep checking after nomination; the ordering logic and the outcomes above are the parts that hold.
AdvancedBeyond one peer connection

Real meeting services rarely connect every participant to every other one. With five people in a full mesh, each browser uploads four copies of its video. Instead, participants send to a server:

  • An SFU (selective forwarding unit) receives each participant's streams and forwards them, without decoding, to everyone else. Senders often use simulcast: they encode the same camera at two or three resolutions, and the SFU forwards the one each receiver can handle. This is the usual design for browser-based meetings, because it keeps each browser's upload to one set of streams.
  • An MCU (multipoint control unit) decodes everyone, composes one mixed picture and re-encodes it per participant. It costs far more server CPU but sends each endpoint a single stream, which is why traditional room systems and H.323/SIP bridges used MCUs.

Two more API details come up in interviews. Renegotiation: adding a screen-share track fires negotiationneeded, and you do another offer/answer round on the same connection; if both sides start one at once ("glare"), the W3C's perfect negotiation pattern makes one side polite (it rolls back its own offer) and the other impolite. ICE restart: when the network changes (Wi-Fi to cellular), restartIce() gathers fresh candidates with new credentials while the call keeps going.

AdvancedGotcha: no TURN server in production
Why port 9?

Every m= line in Chrome's offer says port 9 and c=IN IP4 0.0.0.0. Has the browser chosen to receive media on port 9?

Show answer

No. With trickle ICE, port 9 and 0.0.0.0 are placeholders; the real addresses come as ICE candidates.

What’s happening
  1. Classic SDP put the one receive address in c= and the port in m=, which is what the SIP model does (198.51.100.10:49170).
  2. JSEP generates the offer before candidate gathering has finished (that's the point of trickle ICE, a=ice-options:trickle), so it has no address to write. It uses the "discard" port 9 and the unspecified address 0.0.0.0 instead, as JSEP requires (RFC 9429: "the default <port> value of 9 (Discard) MUST be used").
  3. The real addresses arrive as a=candidate lines, either in later icecandidate events (trickled) or in localDescription once gathering completes. In the loopback runs they were host candidates on high ephemeral ports (61258, 62458 and 65095 in one run).
  4. Once ICE selects a pair, media flows on that pair's addresses, which can be completely different from anything in c=. A hardware endpoint that ignores ICE and sends RTP to the c=/m= address will send to 0.0.0.0:9 and nothing will arrive.

#Room systems and provider modes

Added

A room system is a meeting-room appliance: a codec (the computer that encodes and decodes the media), cameras, microphones and speakers, one or two displays, and a touch controller on the table. Modern ones are video bars or small boxes that run an embedded OS rather than a PC. The interesting design decision for a device platform is that the same hardware can run different meeting services. Customers standardised on Microsoft Teams, Zoom or Google Meet want the room to be a first-class Teams, Zoom or Meet endpoint, not a SIP box dialling in through a gateway, so the vendor ships the service's own room app on the device and lets the admin pick which one runs. Poly calls this choice the provider; running a third-party one is Partner Mode.

The mental model: a games console that boots into one game at a time. The console (the hardware plus Poly VideoOS) owns the things every game needs: the network, the controllers (cameras, microphones, the touch panel), security, updates and the settings menu. The game (Teams Rooms, Zoom Rooms, Meet, or Poly's own video app) owns everything about playing: the meeting UI, calendar, calling, and its own online account. Switching games means a restart, the console's settings survive the switch, and each game is managed through its own online service. Most questions about "where do I configure X on this room?" are answered by asking which of the two owns X.

Inside the room system Who manages it Provider app (one at a time) Poly Video SIP · H.323 Teams Rooms Zoom Rooms Google Meet Device Mode Poly VideoOS (Android-based) network · peripherals · security · updates local web interface · REST API · CLI API provider choice lives here Hardware codec · cameras · mics · displays touch controller (TC8 / TC10) Provider's cloud Teams admin center · Intune Zoom device management Google Admin console Device management local web UI · REST / CLI provisioning server Poly Lens (cloud)
The device layer is the platform's job in every mode; the provider layer belongs to whichever meeting service is running.

What the public documentation says about Poly's provider modes

Everything in this section comes from Poly/HP's public guides and the meeting vendors' own pages; where they don't say something, neither does this page. HP's release notes and guides call the operating system Poly VideoOS; HP's community knowledge-base posts title the same releases "PolyOS" (for example "PolyOS 5.0.0"), which is the name the role description uses. They're the same product line (see chapter 1).

FactSource
By default a system runs in Poly Video mode; to use Teams Rooms, Zoom Rooms or Google Meet you need that provider's account and licence. You choose the provider during the out-of-box setup or later in the web interface.Poly Video Mode Administrator Guide 4.7.0 (July 2025)
On first boot the system asks Poly's Zero Touch Onboarding (ZTO) server for its mode. If the app ZTO names isn't in the installed software, the system updates; if it's still not available, it falls back to Poly Video Mode.same guide
To change provider: General Settings > Provider, choose one, Save, then Restart; the system keeps previously configured settings and updates its ZTO profile.same guide; Partner Mode Administrator Guide 4.7.0 (September 2026)
Partner applications listed for VideoOS 4.7: Microsoft Teams Rooms, Zoom Rooms, Google Meet, Dialpad, GoToRoom, RingCentral Rooms and Tencent. BlueJeans was dropped in 4.2.0 (systems still on it moved to Poly Video Mode). Dialpad and Google Meet aren't supported on the G7500; the Studio G62 is certified for Teams Rooms, Zoom Rooms and Tencent only.Partner Mode Administrator Guide 4.7.0
Device Mode lets a computer connected by USB use the system's camera, speaker, microphone and monitors with the computer's own meeting app; set as the provider, it locks the system into Device Mode.Video Mode Administrator Guide 4.7.0
Wi-Fi can be the primary network only with Zoom Rooms or Microsoft Teams Rooms; Poly Video Mode, Device Mode, GoToRoom, RingCentral Rooms and Dialpad need Ethernet.Partner Mode Administrator Guide 4.7.0
Google Meet arrived on the Studio X30, X50 and X70 with Poly OS 4.0, managed in the Google Admin console with Meet hardware licences.Google Workspace Updates blog, 9 March 2023
VideoOS 4.0.0 (March 2023) was a "Platform Update to Android 10"; 4.1.0 (August 2023) moved the TC8/TC10 controllers to Android 11.Poly TC5.0 release notes, release history (September 2023)
VideoOS 5.0 runs on the Studio G62, X72, X52 and X32 only, offers two providers, Microsoft Teams Rooms and Poly Device Mode, and can't be downgraded to 4.x. Its table marks Zoom and Google Meet "Not Supported".Poly VideoOS 5.0 Compatibility Reference Guide (March 2026)
VideoOS 5.0.0 (May 2026) moves those systems "from Android 10 to Android 13" and the TC10 touch controller "from Android 11 to Android 12"; partner applications "are no longer bundled in the main software package" and download after the upgrade; the release "supports only Microsoft Teams and Poly Device Mode". HP's community post for the release lists the providers 5.0.0 doesn't support: Poly/SIP, Google Meet, RingCentral, Dialpad, GoTo Meeting and Tencent (it doesn't name Zoom Rooms, which the compatibility guide above marks "Not Supported").Poly VideoOS Software Release Notes 5.X, HP Support (read 27 October 2026); HP Poly community knowledge base post "PolyOS 5.0.0", 6 May 2026
The X32, X52, X72 and G62 are re-certified for Zoom Rooms on Android 13 (and the TC10 on Android 12); Poly plans Android 17 for them in 2028 and support through 2032.Poly Lens blog, "Future-Proofing Collaboration with HP Poly Video and Zoom", 3 March 2026
Microsoft's Teams Rooms on Android certified list shows the Studio X32, X52, X72 and G62 on Android 13 with firmware 5.0.0.418601, certified until 15 August 2028, and the TC10 on Android 12. "Certification end dates are based on the Android version" the device ran when it entered the programme.Microsoft Learn, "Teams Rooms on Android certified devices" (updated 23 October 2026)
Teams Android devices now enrol in Intune with AOSP Device Management, which "replaced the legacy Device Administrator enrollment method"; Teams Rooms Basic licences don't include Intune.Microsoft Learn, "Intune Enrollment for Teams Android Devices" (updated December 2025)

Two things in that table are worth saying out loud in an interview because they're exactly the problems a platform team lives with. First, the OS migration changes what the product can do: the 5.0 compatibility guide (March 2026) lists only Teams Rooms and Device Mode, while Poly's own blog the same month announces Zoom Rooms re-certification on Android 13, so Zoom support is evidently coming in a later 5.x release; the public documents I found don't say which. Second, 5.0 is one-way: an upgrade that can't be rolled back has to be staged and tested hard before it reaches a fleet. (The role description, and this site's outline, describe the move as Android 11 → 13; HP's 5.0.0 release notes say the codecs go from Android 10 to 13 and the TC10 controller from 11 to 12. Chapter 1 suggests how to raise that in the interview.)

The model: changing the provider

This model encodes the provider-change rules from those guides: which providers a model offers on which software line, the Wi-Fi restriction, and what happens on a successful change.

provider-switch.mjs
// Changing a room system's conferencing provider, following the rules Poly's public guides document:
// Poly Partner Mode / Video Mode Administrator Guides 4.7.0 and the Poly VideoOS 5.0 Compatibility Reference
// Guide (March 2026). This is a model of the documented behaviour, not Poly's code.
// Only providers the guides confirm for each model are listed (the per-model matrix is in the release notes).
const providers = {
  "4.7": {
    "Studio X50": ["Poly Video", "Device Mode", "Microsoft Teams Rooms", "Zoom Rooms", "Google Meet"],
    "Studio G62": ["Poly Video", "Device Mode", "Microsoft Teams Rooms", "Zoom Rooms", "Tencent"],
  },
  "5.0": { "Studio X52": ["Microsoft Teams Rooms", "Device Mode"] },
};
const WIFI_PRIMARY_OK = ["Zoom Rooms", "Microsoft Teams Rooms"];

function changeProvider(device, target) {
  const steps = [`${device.model} on VideoOS ${device.version}, ${device.network}: ${device.provider} → ${target}`];
  const offered = providers[device.version][device.model];
  if (!offered.includes(target)) return [...steps, `  ✗ ${target} isn't a provider for this model on ${device.version}`];
  if (device.network === "Wi-Fi primary" && !WIFI_PRIMARY_OK.includes(target))
    return [...steps, `  ✗ ${target} doesn't support Wi-Fi as the primary network: connect Ethernet first`];
  if (!["Poly Video", "Device Mode"].includes(target)) steps.push(`  · needs a ${target} account and licence for the room`);
  steps.push("  · web UI: General Settings > Provider, choose it, Save, Restart");
  steps.push("  · settings configured earlier are kept; the Zero Touch Onboarding profile is updated");
  steps.push(`  ✓ system restarts and launches ${target}`);
  return steps;
}

const cases = [
  [{ model: "Studio X50", version: "4.7", network: "Ethernet", provider: "Poly Video" }, "Zoom Rooms"],
  [{ model: "Studio X50", version: "4.7", network: "Wi-Fi primary", provider: "Zoom Rooms" }, "Google Meet"],
  [{ model: "Studio G62", version: "4.7", network: "Ethernet", provider: "Poly Video" }, "Google Meet"],
  [{ model: "Studio X52", version: "5.0", network: "Ethernet", provider: "Microsoft Teams Rooms" }, "Zoom Rooms"],
  [{ model: "Studio X52", version: "5.0", network: "Ethernet", provider: "Microsoft Teams Rooms" }, "Device Mode"],
];
for (const [device, target] of cases) console.log(changeProvider(device, target).join("\n"));
Result
Studio X50 on VideoOS 4.7, Ethernet: Poly Video → Zoom Rooms
  · needs a Zoom Rooms account and licence for the room
  · web UI: General Settings > Provider, choose it, Save, Restart
  · settings configured earlier are kept; the Zero Touch Onboarding profile is updated
  ✓ system restarts and launches Zoom Rooms
Studio X50 on VideoOS 4.7, Wi-Fi primary: Zoom Rooms → Google Meet
  ✗ Google Meet doesn't support Wi-Fi as the primary network: connect Ethernet first
Studio G62 on VideoOS 4.7, Ethernet: Poly Video → Google Meet
  ✗ Google Meet isn't a provider for this model on 4.7
Studio X52 on VideoOS 5.0, Ethernet: Microsoft Teams Rooms → Zoom Rooms
  ✗ Zoom Rooms isn't a provider for this model on 5.0
Studio X52 on VideoOS 5.0, Ethernet: Microsoft Teams Rooms → Device Mode
  · web UI: General Settings > Provider, choose it, Save, Restart
  · settings configured earlier are kept; the Zero Touch Onboarding profile is updated
  ✓ system restarts and launches Device Mode
What’s happening
  1. providers holds only what the guides confirm per model: the Studio X50 on 4.7 with Teams Rooms, Zoom Rooms and Google Meet (Meet confirmed for the X50 since 4.0); the Studio G62 with Teams Rooms, Zoom Rooms and Tencent; and the Studio X52 on 5.0 with Teams Rooms and Device Mode only. The guides point to the release notes for the full per-model matrix, which I didn't check.
  2. The first change succeeds and prints the documented steps: a Zoom Rooms account and licence for the room, General Settings > Provider, Save, Restart, settings kept, ZTO profile updated, and the system boots into Zoom Rooms.
  3. The second case is on Wi-Fi as its primary network. That's allowed in Zoom Rooms, but Google Meet isn't one of the two providers (Zoom Rooms and Teams Rooms) that support Wi-Fi as primary, so the change is refused with the fix: connect Ethernet first. (The Partner Mode guide's explicit list of providers that can't use Wi-Fi as primary names Poly Video Mode, Device Mode, GoToRoom, RingCentral Rooms and Dialpad, not Google Meet or Tencent; the model follows the guide's note that "Only Zoom Rooms and Microsoft Teams support a Wi-Fi connection as the primary network".) In a real web UI you'd show this before the admin presses Save.
  4. The G62 can't run Google Meet at all, and the X52 on VideoOS 5.0 can't run Zoom Rooms (yet), so both are refused with the reason, not a generic error. The last case, Device Mode on 5.0, succeeds and needs no licence.
  5. What this demonstrates for a web UI or API: the list of choices depends on the model and the software version, a provider change is a restart-required operation, and some choices depend on other settings (the primary network). All three belong in the API's validation, so the UI, the REST API and provisioning all refuse the same things for the same reasons.
AdvancedProvider-aware settings registry and API

Designing settings that know which mode they belong to

Poly's two admin guides for 4.7 show the split in their structure: the Video Mode guide has Call Configuration sections for SIP and H.323, and the Partner Mode guide has none (its network chapter keeps a one-line mention of them), because in a partner mode the partner app does the calling. A device platform has to express that split somewhere. The model below is an illustrative design, not Poly's API: one registry records which modes each setting applies to, and both the settings list the web UI renders and the PATCH handler use it.

provider-settings.mjs
// Illustrative design (not Poly's API): one settings registry says which provider mode each setting
// applies to, so the web UI and the REST API make the same decision.
import express from "express";
import request from "supertest";

const REGISTRY = {
  "device.name":        { modes: "all" },
  "network.vlanId":     { modes: "all", restart: true },
  "sip.registrar":      { modes: ["polyVideo"] },   // calling belongs to Poly Video mode;
  "h323.gatekeeper":    { modes: ["polyVideo"] },   // in a partner mode the partner app owns it
  "provider":           { modes: "all", restart: true },
};
const device = { provider: "teamsRooms", values: { "device.name": "Room 4.12", "network.vlanId": 0, "sip.registrar": "", "h323.gatekeeper": "", provider: "teamsRooms" } };
const applies = (key) => REGISTRY[key].modes === "all" || REGISTRY[key].modes.includes(device.provider);

const app = express();
app.use(express.json());
app.get("/api/settings", (req, res) => {
  res.json(Object.keys(REGISTRY).map((key) => ({ key, value: device.values[key], editable: applies(key) })));
});
app.patch("/api/settings", (req, res) => {
  const keys = Object.keys(req.body);
  const unknown = keys.find((k) => !REGISTRY[k]);
  if (unknown) return res.status(422).type("application/problem+json").json({ type: "/problems/unknown-setting", title: "Unknown setting", detail: unknown });
  const blocked = keys.filter((k) => !applies(k));
  if (blocked.length) return res.status(409).type("application/problem+json").json({
    type: "/problems/setting-not-applicable", title: "Setting doesn't apply in the current provider mode",
    detail: `${blocked.join(", ")} ${blocked.length > 1 ? "apply" : "applies"} only in Poly Video mode; the provider is ${device.provider}`, provider: device.provider,
  });
  Object.assign(device.values, req.body);
  const restart = keys.some((k) => REGISTRY[k].restart);
  res.status(restart ? 202 : 200).json({ applied: keys, restartRequired: restart });
});

const show = (r) => console.log(`${r.req.method} ${r.req.path} → ${r.status} ${JSON.stringify(r.body)}`);
const editable = (await request(app).get("/api/settings")).body.map((s) => `${s.key}${s.editable ? "" : " (read-only)"}`);
console.log(`provider ${device.provider}: ${editable.join(", ")}`);
show(await request(app).patch("/api/settings").send({ "device.name": "Boardroom" }));
show(await request(app).patch("/api/settings").send({ "sip.registrar": "sip.example.com" }));
show(await request(app).patch("/api/settings").send({ provider: "polyVideo" }));
Result
provider teamsRooms: device.name, network.vlanId, sip.registrar (read-only), h323.gatekeeper (read-only), provider
PATCH /api/settings → 200 {"applied":["device.name"],"restartRequired":false}
PATCH /api/settings → 409 {"type":"/problems/setting-not-applicable","title":"Setting doesn't apply in the current provider mode","detail":"sip.registrar applies only in Poly Video mode; the provider is teamsRooms","provider":"teamsRooms"}
PATCH /api/settings → 202 {"applied":["provider"],"restartRequired":true}
What’s happening
  1. REGISTRY is the single source of truth: device.name and network.vlanId apply in every mode (the device owns them), sip.registrar and h323.gatekeeper only in polyVideo. restart: true marks settings that need a reboot, including provider itself.
  2. GET /api/settings returns each setting with editable, computed from the current provider. In teamsRooms mode the SIP and H.323 settings come back read-only, so the web UI can grey them out with an explanation instead of hiding them and leaving the admin confused.
  3. Renaming the device returns 200 with restartRequired: false: it applies in every mode and takes effect at once.
  4. Setting a SIP registrar in Teams Rooms mode returns 409 Conflict with an RFC 9457 problem body: a stable type partners can test against, and a detail that says why. 409 fits because the request is valid in general but conflicts with the device's current state; 403 would wrongly suggest a permissions problem.
  5. Changing provider returns 202 Accepted with restartRequired: true: the value is stored, and the change happens when the system restarts. That's the same apply-and-restart flow as any restart-required setting (see Apply, restart and rollback flows).
AdvancedProvider apps and who manages them

What a room system runs, and what you can and can't see

On these devices the provider apps are Android apps from the meeting vendors, running on Poly's Android-based OS: the TC5.0 release notes list them as separately versioned "partner applications" (for example a Microsoft Teams Rooms build and a Zoom Rooms version), and Microsoft's certified-devices page describes firmware releases that bundle the Teams, Authenticator, Intune and Admin Agent apps. How those apps talk to the camera, audio and peripheral services inside VideoOS is not something the public documentation describes, so in an interview talk about the pattern (a vendor app on top of platform services, with a contract between them that both sides version and test) rather than guessing at internals.

Management splits the same way as the layers:

  • Device level, any mode: the local web interface, the REST and command-line APIs, provisioning servers (Poly Clariti Manager or Poly Lens provisioning), and Poly Lens, which the guides describe as "cloud-based management and insights".
  • Teams Rooms: the Teams admin center and the Teams Rooms Pro Management portal for the app and its updates, Intune (AOSP Device Management) for enrolment and compliance.
  • Zoom Rooms: Zoom's own device management in its web portal (Zoom's "Managing Zoom Clients and Devices Explainer").
  • Google Meet: the Google Admin console; the room shows a code, and the admin enrols it under Devices > Google Meet hardware (Partner Mode Administrator Guide 4.7.0).
AdvancedGotcha: provider support changes with software
Where does the admin configure the SIP registrar?

A room system is running Microsoft Teams Rooms. A customer's admin wants it to register to their SIP server so it can be called by SIP address. Using the guides quoted above, what's the answer?

Show answer

Not on the device in Teams Rooms mode: the device's SIP settings belong to Poly Video mode. Calls between Teams and SIP/H.323 systems go through Teams' own interop options.

What’s happening
  1. In Teams Rooms mode, calling is done by the Teams Rooms app and its cloud. The Poly SIP and H.323 settings (Call Configuration in the Video Mode guide) are for Poly Video mode, and the Partner Mode guide has no such configuration section.
  2. Switching the provider to Poly Video mode would make the SIP settings apply, but the room would then stop being a Teams Rooms device: a different product decision, made through General Settings > Provider and a restart.
  3. Microsoft's route for SIP and H.323 video systems is Cloud Video Interop, which lets those systems join Teams meetings through a certified partner (Pexip, Cisco, or Poly CloudConnect powered by Pexip). Its release notes also list "SIP and H.323 Calling with Teams Rooms" as a Teams Rooms Pro feature, currently noted for Teams Rooms on Windows.
  4. So the correct answer for a device platform engineer is about ownership: say which layer owns calling in that mode, point to the provider's interop option, and make sure the device UI shows the SIP settings as "not used in Teams Rooms mode" rather than letting the admin fill them in for nothing.

#Media quality and diagnostics

Added

When someone says "the call was bad", they mean one of a few things: the audio broke up, the video froze or went blocky, there was a delay that made people talk over each other, or the picture was low resolution. Each of these has a network cause (not enough bandwidth, packets lost, packets delayed by varying amounts) or an endpoint cause (CPU too busy to encode, a poor camera feed, a codec mismatch), and each leaves a trace in the call statistics. A device platform's job is to measure the right numbers, expose them through the web UI and APIs, and make them easy to interpret, because the person reading them is usually an AV technician on a ladder, not a media engineer.

The mental model is a conveyor belt of parcels into a waiting room. The sender puts a parcel (packet) on the belt every 20 ms. Bandwidth is how many parcels the belt can carry. Latency is how long the trip takes. Jitter is how much that trip time varies: some parcels arrive 2 ms early, some 40 ms late. Packet loss is parcels that fall off. The receiver can't play parcels the moment they arrive, because then every late one would be a gap, so it keeps a jitter buffer: a small waiting room that holds parcels long enough to put them back in a steady rhythm. A bigger waiting room absorbs more jitter but adds delay. Parcels that arrive after their slot are as good as lost.

Sender Network Receiver Capture Encode RTP pack loss · delay jitter Jitter buffer Decode, conceal Render fps resolution bitrate CPU limit packets sent loss % round trip jitter buffer delay concealment freezes frames shown RTCP feedback, receiver → sender loss and jitter reports · NACK · PLI · bandwidth estimate
Each stage has its own numbers; the receiver's reports flow back so the sender can resend, send a keyframe or lower its bitrate.

The numbers that matter, what they mean and what moves them:

MetricWhat it measuresTypical cause when it's bad
Bitrate (sent / received)how much media actually flows, against what was negotiatedbandwidth limits, congestion control backing off, or a simple picture compressing well
Packet loss %packets that never arrived, over some windowcongested links, bad Wi-Fi, policers dropping bursts
Interarrival jittervariation in packet transit time (RFC 3550)queues filling and draining, Wi-Fi retransmissions, VPNs
Round-trip timetime to the far end and back, from RTCPdistance, routing, buffering in the network
Frame rate and resolutionwhat the encoder sent and the decoder showedbandwidth adaptation, CPU limits, camera settings
Concealment, freezeshow often the receiver had to invent or hold medialoss and late packets the buffer couldn't hide

For a yardstick, Microsoft's Teams guidance (Microsoft Learn training module "Understand Teams network requirements", updated September 2026) sets targets for the whole path from a client to Microsoft's network edge: one-way latency under 50 ms, round trip under 100 ms, packet loss under 1% in any 15-second interval, burst loss under 10% in any 200-ms interval, and jitter under 30 ms in any 15-second interval. For your own network edge to Microsoft's, the targets are tighter: under 0.1% loss and 15 ms jitter. They're Teams' numbers, not a universal standard, but they're a sensible scale for any real-time media.

The model: measuring jitter the way RTP does

Jitter isn't the standard deviation of delay, and it isn't the delay itself. RFC 3550 (RTP, 2003) defines it precisely: for each packet, compare how far apart two packets arrived with how far apart they were sent (their RTP timestamps), and keep a smoothed average of that difference. Every RTCP receiver report carries this number, and every device's "jitter" statistic is, or should be, this.

rfc3550-jitter.mjs
// RTP interarrival jitter, exactly as RFC 3550 §6.4.1 / Appendix A.8 defines it, for three networks.
// Audio: one packet every 20 ms, RTP clock 48 kHz (Opus), so the timestamp advances 960 per packet.
function mulberry32(seed) { // small seeded PRNG so the output is the same on every run
  return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const CLOCK = 48000, STEP = 960, PACKETS = 500;

function measure(name, delayMs) {
  let jitter = 0, prev = null;
  const samples = [];
  for (let i = 0; i < PACKETS; i++) {
    const rtpTs = i * STEP;                          // S: sender's RTP timestamp
    const sentMs = i * 20;
    const arrival = ((sentMs + delayMs(i)) / 1000) * CLOCK; // R: arrival time, in RTP clock units
    if (prev) {
      const D = (arrival - prev.arrival) - (rtpTs - prev.rtpTs); // change in transit time
      jitter += (Math.abs(D) - jitter) / 16;                     // J = J + (|D| - J) / 16
    }
    prev = { arrival, rtpTs };
    if (i === 99 || i === 299 || i === 499) samples.push(`${(jitter / CLOCK * 1000).toFixed(1).padStart(5)} ms`);
  }
  console.log(`${name.padEnd(38)} ${samples.join("  ")}`);
}

const r1 = mulberry32(7), r2 = mulberry32(7);
console.log(`${"network (one-way delay per packet)".padEnd(38)} J after 100, 300 and 500 packets`);
measure("constant 120 ms delay", () => 120);
measure("wired: 40 ms ± up to 2 ms", () => 40 + (r1() - 0.5) * 4);
measure("Wi-Fi: 40 ms, 1 in 10 late by 30-60 ms", () => 40 + (r2() < 0.1 ? 30 + r2() * 30 : r2() * 4));
measure("delay ramps 40 → 140 ms (queue fills)", (i) => 40 + Math.min(i, 250) * 0.4);
Result
network (one-way delay per packet)     J after 100, 300 and 500 packets
constant 120 ms delay                    0.0 ms    0.0 ms    0.0 ms
wired: 40 ms ± up to 2 ms                1.2 ms    1.4 ms    1.2 ms
Wi-Fi: 40 ms, 1 in 10 late by 30-60 ms   8.4 ms   12.7 ms   12.6 ms
delay ramps 40 → 140 ms (queue fills)    0.4 ms    0.0 ms    0.0 ms
What’s happening
  1. The sender emits one packet every 20 ms with an RTP timestamp that advances by 960 (20 ms of a 48 kHz clock, Opus's RTP clock). The receiver records each arrival time in the same units. D is the change in transit time between consecutive packets: (arrival gap) − (send gap). If both packets took the same time to cross the network, D is 0, whatever that time was.
  2. jitter += (|D| − jitter) / 16 is RFC 3550's estimator, a running average that moves 1/16 of the way to each new sample, so one odd packet nudges it and a sustained change dominates it. The value is in timestamp units; dividing by 48,000 and multiplying by 1,000 gives milliseconds.
  3. A constant 120 ms delay gives 0.0 ms jitter. High latency and high jitter are different problems: a satellite link can be smooth, and a short Wi-Fi hop can be awful. The wired network, with ±2 ms of noise, settles around 1.2–1.4 ms.
  4. The Wi-Fi network (the PRNG is seeded, so the run is reproducible) delays one packet in ten by an extra 30–60 ms. Its jitter climbs to about 12.6 ms: under Teams' 30 ms target, yet enough to make a fixed 10–20 ms jitter buffer drop packets, as the next model shows.
  5. The last row is the trap: delay rises from 40 to 140 ms as a queue fills, yet jitter is only 0.4 ms while it ramps and 0.0 once it stops. A slowly filling queue (bufferbloat) shows up as rising round-trip time, not as jitter. To catch it you need RTT from RTCP alongside jitter, which is why statistics pages show both.
AdvancedJitter buffers: buying smoothness with delay

The receiver decides how long to hold packets before playing them. Too short and late packets become gaps; too long and the conversation gets laggy. This model replays 60 seconds of the same seeded Wi-Fi network against fixed buffers and a simple adaptive one.

jitter-buffer.mjs
// A jitter buffer trades delay for completeness: hold packets longer and fewer arrive too late to play.
// Same seeded "Wi-Fi" network as the jitter model: 40 ms base delay, 1 packet in 10 delayed by 30-60 ms.
function mulberry32(seed) {
  return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
const rand = mulberry32(7);
const delays = Array.from({ length: 3000 }, () => 40 + (rand() < 0.1 ? 30 + rand() * 30 : rand() * 4)); // 60 s of 20 ms packets

// Packet i is played at: its send time + 44 ms (the normal 40-44 ms path) + the buffer. Anything later is discarded.
const lateShare = (bufferFor) => delays.filter((d, i) => d > 44 + bufferFor(i)).length / delays.length;
console.log("buffer          added delay   packets too late to play");
for (const buffer of [0, 15, 30, 45, 60]) {
  console.log(`fixed ${String(buffer).padStart(3)} ms    ${String(buffer).padStart(5)} ms     ${(lateShare(() => buffer) * 100).toFixed(1).padStart(5)}%`);
}

// Adaptive: size the buffer from what the last 100 packets needed (their 95th percentile), as real
// receivers do in spirit (their algorithms are more elaborate).
const adaptive = delays.map((_, i) => {
  const recent = delays.slice(Math.max(0, i - 100), i).sort((a, b) => a - b);
  return recent.length ? Math.max(0, recent[Math.floor(recent.length * 0.95)] - 44) : 20;
});
const avg = adaptive.reduce((a, b) => a + b, 0) / adaptive.length;
console.log(`adaptive p95    ${avg.toFixed(0).padStart(5)} ms avg ${(lateShare((i) => adaptive[i]) * 100).toFixed(1).padStart(5)}%`);
Result
buffer          added delay   packets too late to play
fixed   0 ms        0 ms      10.4%
fixed  15 ms       15 ms      10.4%
fixed  30 ms       30 ms       8.9%
fixed  45 ms       45 ms       3.7%
fixed  60 ms       60 ms       0.0%
adaptive p95       40 ms avg   5.0%
What’s happening
  1. delays is 3,000 packets (60 s at 50 packets per second) from the same Wi-Fi model as before: normally 40–44 ms, but about one packet in ten is delayed by an extra 30–60 ms. Each packet is scheduled to play at its send time + 44 ms (the normal path) + the buffer; one arriving after that is discarded.
  2. With no buffer, 10.4% of packets are too late: every spike. A 15 ms buffer doesn't help at all, because no spike is shorter than 30 ms extra. Even 30 ms saves only a few. This is the uncomfortable shape of real networks: jitter comes in spikes, and a buffer has to cover the spikes, not the average.
  3. At 45 ms the late share drops to 3.7%, and at 60 ms to 0.0%, but every word now reaches the listener 60 ms later. Add that to the network delay and encoding time and you're quickly past the point where people start talking over each other.
  4. The adaptive buffer sizes itself from the 95th percentile of the last 100 packets' delays, so it aims to play about 95% of packets. It averages 40 ms of buffering and loses 5.0%, a middle point it reached by itself. Real receivers (for audio in Google's libwebrtc, a component called NetEq) are much more elaborate: they stretch and shrink audio to change the delay smoothly, and conceal lost packets instead of leaving silence.
  5. The device lesson: "jitter 12 ms" on a statistics page doesn't tell you whether audio broke up. The numbers that do are the receiver's late or concealed packets and its current buffer delay, which is why WebRTC's stats expose jitterBufferDelay and concealment counters alongside jitter.
AdvancedWhat loss does to video and to audio

One lost packet doesn't cost the same everywhere. In video, a frame is split into several packets and one missing packet damages the whole frame; in audio, codecs can hide a single gap. Retransmission and forward error correction help in different situations.

loss-effects.mjs
// How packet loss turns into what people see and hear.
// 1) Video: a 2 Mbps, 30 fps stream sends about 7 packets per frame; one lost packet damages the frame.
const PER_FRAME = 7, FPS = 30;
console.log("loss   damaged frames/min   with one NACK retransmission");
for (const p of [0.001, 0.005, 0.01, 0.02, 0.05]) {
  const damaged = (q) => (1 - (1 - q) ** PER_FRAME) * FPS * 60;
  // If the round trip fits in the jitter buffer, a NACK gets one more try: the packet is gone only if
  // the original and the retransmission are both lost (assumed independent here).
  console.log(`${(p * 100).toFixed(1).padStart(4)}%  ${damaged(p).toFixed(1).padStart(12)}         ${damaged(p * p).toFixed(2).padStart(10)}`);
}

// 2) Audio: Opus in-band FEC carries a low-bitrate copy of packet n inside packet n+1, so a single lost
// packet can be rebuilt when the next one arrives. Bursts defeat it. Same 5% loss, two shapes (seeded).
function mulberry32(seed) {
  return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0; let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
}
function lossPattern(n, rand, enterBad, leaveBad) { // two-state Gilbert model: good (no loss) / bad (all lost)
  let bad = false;
  return Array.from({ length: n }, () => (bad = bad ? rand() >= leaveBad : rand() < enterBad));
}
const N = 50_000; // about 17 minutes of 20 ms packets
const shapes = {
  "random 5%": lossPattern(N, mulberry32(1), 0.05, 0.95),        // bad state almost always lasts 1 packet
  "bursts of ~4 (5%)": lossPattern(N, mulberry32(1), 0.0132, 0.25), // mean burst 1/0.25 = 4 packets
};
console.log("\naudio pattern        lost    still missing after FEC");
for (const [name, lost] of Object.entries(shapes)) {
  const total = lost.filter(Boolean).length;
  const unrecovered = lost.filter((l, i) => l && lost[i + 1] !== false).length; // next packet also lost (or none)
  console.log(`${name.padEnd(20)} ${((total / N) * 100).toFixed(1).padStart(4)}%   ${((unrecovered / N) * 100).toFixed(1).padStart(4)}%`);
}
Result
loss   damaged frames/min   with one NACK retransmission
 0.1%          12.6               0.01
 0.5%          62.1               0.31
 1.0%         122.3               1.26
 2.0%         237.4               5.03
 5.0%         543.0              31.26

audio pattern        lost    still missing after FEC
random 5%             4.9%    0.2%
bursts of ~4 (5%)     4.8%    3.5%
What’s happening
  1. A 2 Mbit/s, 30 fps stream sends about 7 packets per frame (the overhead model's arithmetic). A frame survives only if all 7 arrive, so with loss p the damaged share is 1 − (1 − p)^7. At 1% loss that's 122 damaged frames a minute, about 7% of all frames: a 1% packet-loss number on a dashboard means much more than 1% bad video.
  2. And it's worse than that count: compressed video frames refer to earlier frames, so one damaged frame corrupts the following ones until a keyframe arrives. That's what the RTCP nack pli in the SDP is for: the receiver sends a Picture Loss Indication and the sender produces a fresh keyframe, which is large and costs a burst of bandwidth.
  3. With one NACK retransmission, a packet is lost only if the original and the resend are both lost: at 1% loss, 1.26 damaged frames a minute instead of 122. This only works if the round trip fits inside the receiver's buffer, so retransmission helps on a 30 ms path and does nothing for a 300 ms one. The model assumes the two losses are independent, which bursts break.
  4. Audio uses Opus's in-band FEC: each packet carries a low-bitrate copy of the previous one, so a single lost packet can be rebuilt when the next arrives. With 5% random loss, only 0.2% of packets stay missing.
  5. The same 5% average loss in bursts of about four packets (a seeded two-state Gilbert model) leaves 3.5% missing, because FEC can't rebuild a packet whose successor is also gone. The shape of the loss matters as much as the percentage, which is why Microsoft's targets have a separate burst-loss limit over 200 ms windows.
AdvancedWhat a device exposes: Poly's statistics

Poly's public guides describe three places to read call statistics on its room systems.

  • Web interface: Diagnostics > Call Statistics (also linked from the dashboard during a call) lists participants and their connection quality, then per stream: stream type, quality, protocol, format, rate used, frame rate, packets lost, packet loss percentage, jitter and error concealment (Video Mode Administrator Guide 4.7.0).
  • REST API: GET /rest/conferences lists current calls; GET /rest/conferences/<confID>/mediastats returns one entry per stream with fields such as mediaStream (audioTx, videoRx…), mediaAlgorithm, bitRate, actualBitRate, actualFrameRate, jitter, maxJitter, latency, packetLoss, percentPacketLoss, totalPackets, errorConcealment and the encryption details; it needs an admin session from /rest/session (Poly VideoOS REST API Reference Guide, August 2025).
  • Command-line API (SSH, or Telnet on port 24 or 23 if enabled): advnetstats prints transmit/receive audio and video rates, frame rates, packet loss and jitter for a connection (Poly Studio G62 with VideoOS 4.3.0 Integrator Guide, May 2024). Chapter 6 covers command interfaces like this in depth (see What Telnet is and why devices still expose it).

The model below takes the documented sample outputs of the last two, exactly as the guides print them, and turns them into the kind of summary an admin UI would show.

mediastats-health.mjs
// Turning a device's raw call statistics into something an admin UI can show.
// Input 1: fields from the sample response of GET /rest/conferences/<confID>/mediastats in the
// Poly VideoOS REST API Reference Guide (August 2025), trimmed to the fields used here.
const mediastats = [
  { mediaStream: "audioTx", mediaAlgorithm: "SirenLPR", bitRate: 64, actualBitRate: 64, jitter: 0, packetLoss: 1, percentPacketLoss: 0.0, totalPackets: 763 },
  { mediaStream: "audioRx", mediaAlgorithm: "SirenLPR", bitRate: 64, actualBitRate: 64, jitter: 3, packetLoss: 0, percentPacketLoss: 0.0, totalPackets: 759 },
  { mediaStream: "videoTx", mediaAlgorithm: "H.264-HP", bitRate: 1984, actualBitRate: 1, jitter: 0, packetLoss: 0, percentPacketLoss: 0.0, totalPackets: 3132 },
  { mediaStream: "videoRx", mediaAlgorithm: "H.264-HP", bitRate: 1984, actualBitRate: 1535, jitter: 0, packetLoss: 19, percentPacketLoss: 0.0, totalPackets: 1249, actualFrameRate: 7 },
];
// Input 2: the feedback example of the command-line API's `advnetstats` (Poly Studio G62 with VideoOS 4.3.0
// Integrator Guide, May 2024), exactly as printed there, including its inconsistent units.
const advnetstats = "call:0 tar:96 K rar:96 K tvr:224 K rvr:416 K tvru:219 K rvru:154 K tvfr:29 rvfr:26 vfe:0 " +
  "tapl:0 rapl:0 taj:6 ms raj:5 ms tvpl:0 rvpl:0 tvj:6 ms rvj:11 ms dc:Disabled rsid:Sams RP700 ccaps:9 " +
  "tcr:0 rcr:128 K tcru:0 rcru:128k tcfr:0 rcfr:64 K tcpl:0 rcpl:0";

const parseCli = (line) =>
  Object.fromEntries([...line.matchAll(/([a-z]+):(.*?)(?=\s+[a-z]+:|$)/g)].map(([, k, v]) => [k, v.trim()]));
const number = (v) => Number.parseFloat(v); // "96 K", "128k", "6 ms" → 96, 128, 6

// Yardstick: Microsoft's client-to-Microsoft-edge targets for Teams media (loss < 1%, jitter < 30 ms).
// The REST guide doesn't state jitter's unit; the CLI prints milliseconds, so ms is assumed.
const verdict = (lossPct, jitterMs) => (lossPct >= 1 || jitterMs >= 30 ? "check network" : "ok");

console.log("REST mediastats");
for (const s of mediastats) {
  const ownLoss = (s.packetLoss / s.totalPackets) * 100;
  const flags = [];
  if (Math.abs(ownLoss - s.percentPacketLoss) >= 0.5) flags.push(`device says ${s.percentPacketLoss}% (window not documented)`);
  if (s.actualBitRate < s.bitRate * 0.25) flags.push(`using ${s.actualBitRate} of ${s.bitRate} kbps`);
  console.log(`  ${s.mediaStream.padEnd(8)} ${s.mediaAlgorithm.padEnd(9)} loss ${ownLoss.toFixed(2).padStart(4)}%  jitter ${String(s.jitter).padStart(2)}  ` +
    `${verdict(ownLoss, s.jitter).padEnd(13)} ${flags.join("; ")}`);
}

const cli = parseCli(advnetstats);
console.log("\nadvnetstats (CLI)");
console.log(`  remote ${cli.rsid}, encryption ${cli.dc}`);
console.log(`  video  tx ${number(cli.tvru)}/${number(cli.tvr)} kbps used at ${cli.tvfr} fps · rx ${number(cli.rvru)}/${number(cli.rvr)} kbps at ${cli.rvfr} fps`);
console.log(`  jitter audio rx ${number(cli.raj)} ms, video rx ${number(cli.rvj)} ms · packet loss audio rx ${cli.rapl}, video rx ${cli.rvpl}`);
console.log(`  content rx ${number(cli.rcr)} kbps (units printed as "${cli.rcr}" and "${cli.rcru}")`);
Result
REST mediastats
  audioTx  SirenLPR  loss 0.13%  jitter  0  ok            
  audioRx  SirenLPR  loss 0.00%  jitter  3  ok            
  videoTx  H.264-HP  loss 0.00%  jitter  0  ok            using 1 of 1984 kbps
  videoRx  H.264-HP  loss 1.52%  jitter  0  check network device says 0% (window not documented)

advnetstats (CLI)
  remote Sams RP700, encryption Disabled
  video  tx 219/224 kbps used at 29 fps · rx 154/416 kbps at 26 fps
  jitter audio rx 5 ms, video rx 11 ms · packet loss audio rx 0, video rx 0
  content rx 128 kbps (units printed as "128 K" and "128k")
What’s happening
  1. The mediastats array copies four entries' fields from the REST guide's sample response (the sample in the guide is missing a comma between its last two objects, so it isn't valid JSON as printed; the values here are unchanged). In the August 2025 PDF the two video objects are cut off at the page break when you view the page; they're in the document's text layer, so they appear when you copy or extract the text. advnetstats is the guide's feedback example, character for character.
  2. For each REST stream the model computes its own loss rate, packetLoss ÷ totalPackets. The received video stream lost 19 of 1,249 packets, 1.52%, over Microsoft's 1% target, so it's flagged "check network", while the device's own percentPacketLoss field says 0.0. The guide doesn't define the window of either field (since the call started? the last few seconds?), so the honest UI shows which number it displays and over what period, and the honest API documents it.
  3. The transmitted video stream negotiated 1984 kbps but is using 1: in the sample, that most likely means nothing was being sent at that moment (no camera, or a paused stream). It's flagged because "allowed 2 Mbps, sending nothing" is something an admin wants to notice.
  4. The CLI line is parsed by matching key: tokens and taking everything up to the next key, which handles values with spaces (96 K, 6 ms, the remote system id Sams RP700). The output shows the received video using 154 of 416 kbps at 26 fps, receive jitter 5 ms for audio and 11 ms for video, and no loss.
  5. The last line shows why a parser must normalise: the same guide prints 128 K for one content rate and 128k for the next. Values reach your UI from firmware written over many years; parse defensively, keep the raw value for support, and test the parser against real samples like these.
  6. If you changed the yardstick to Teams' stricter edge-to-edge targets (0.1% loss, 15 ms jitter), the audio-transmit stream at 0.13% would also be flagged. Thresholds are a product decision: put them in configuration, not in the parsing code.
AdvancedBandwidth: call speeds and sizing a site

Bandwidth: what a call speed buys, and sizing a site

Bandwidth planning has two halves: what quality a given call rate gets you, and how much link a building full of rooms needs.

bandwidth-plan.mjs
// 1) What a call speed buys you. People-stream resolutions Poly publishes for Poly-to-Poly calls with the
// "Sharpness" setting (Poly Video Mode Administrator Guide 4.7.0, tables 14-8 and 14-10; 1080p60 camera).
const h264 = [[0, "640x368", 30], [128, "1024x576", 30], [512, "1280x720", 30], [1024, "1920x1080", 30]];
const h265 = [[300, "1280x720", 30], [600, "1920x1080", 30], [1200, "1920x1080", 60]];
const pick = (table, kbps) => table.filter(([min]) => kbps >= min).at(-1);
const bpp = (kbps, res, fps) => { const [w, h] = res.split("x").map(Number); return (kbps * 1000) / (w * h * fps); };

console.log("call speed   H.264                     H.265 (point-to-point SIP only)");
for (const kbps of [384, 768, 1024, 1500, 2048]) {
  const cells = [h264, h265].map((t) => {
    const row = pick(t, kbps);
    return row ? `${row[1]}@${row[2]} (${bpp(kbps, row[1], row[2]).toFixed(3)} bpp)` : "below the table";
  });
  console.log(`${String(kbps).padStart(5)} kbps   ${cells[0].padEnd(25)} ${cells[1]}`);
}

// 2) Sizing a site's internet link for Teams Rooms. Per-endpoint "Recommended" figures for meetings from
// Microsoft Learn, "Prepare your organization's network for Teams" (kbps up/down).
const teams = { audio: [58, 58], video: [2500, 4000], screen: [2500, 2500] };
const rooms = 12;
const sharing = 3; // assume 3 rooms present content at the busiest moment
const up = rooms * (teams.audio[0] + teams.video[0]) + sharing * teams.screen[0];
const down = rooms * (teams.audio[1] + teams.video[1] + teams.screen[1]); // every room receives a share
console.log(`\n${rooms} rooms in meetings at once, ${sharing} sharing: ${(up / 1000).toFixed(1)} Mbps up, ${(down / 1000).toFixed(1)} Mbps down`);
for (const [linkDown, linkUp] of [[100, 20], [200, 50], [500, 100]]) {
  const fits = up <= linkUp * 1000 * 0.8 && down <= linkDown * 1000 * 0.8; // keep 20% headroom
  console.log(`  ${String(linkDown).padStart(3)}/${String(linkUp).padStart(3)} Mbps link: ${fits ? "fits with 20% headroom" : "too small"}`);
}
Result
call speed   H.264                     H.265 (point-to-point SIP only)
  384 kbps   1024x576@30 (0.022 bpp)   1280x720@30 (0.014 bpp)
  768 kbps   1280x720@30 (0.028 bpp)   1920x1080@30 (0.012 bpp)
 1024 kbps   1920x1080@30 (0.016 bpp)  1920x1080@30 (0.016 bpp)
 1500 kbps   1920x1080@30 (0.024 bpp)  1920x1080@60 (0.012 bpp)
 2048 kbps   1920x1080@30 (0.033 bpp)  1920x1080@60 (0.016 bpp)

12 rooms in meetings at once, 3 sharing: 38.2 Mbps up, 78.7 Mbps down
  100/ 20 Mbps link: too small
  200/ 50 Mbps link: fits with 20% headroom
  500/100 Mbps link: fits with 20% headroom
What’s happening
  1. h264 and h265 are Poly's published people-stream tables for calls between two Poly systems with the camera set to Sharpness (Video Mode Administrator Guide 4.7.0, tables 14-10 and 14-8): for each call speed, the resolution and frame rate the systems use. pick() finds the highest row at or below the call speed.
  2. At 768 kbps H.264 gives 1280 × 720 at 30 fps, while H.265 already gives 1920 × 1080 at 30; at 1500 kbps H.265 doubles the frame rate to 60. That's HEVC's efficiency in Poly's own numbers, and the guide's restriction (H.265 only in point-to-point SIP calls between Poly systems) is the price: in a multiparty call or with another vendor's endpoint, H.264 applies.
  3. bpp (bits per pixel per frame) is call speed ÷ (width × height × fps). It's rough, because the call speed also carries audio, but it shows the pattern: these codecs work at a few hundredths of a bit per pixel, and at 1024 kbps both codecs run 1080p30 at the same 0.016 bpp, with H.265 spending the efficiency on quality rather than resolution.
  4. The site estimate uses Microsoft's per-endpoint "Recommended" figures for Teams meetings (audio 58/58, video 2,500 up / 4,000 down, screen sharing 2,500/2,500 kbps; Microsoft Learn, "Prepare your organization's network for Teams", updated July 2026). Twelve rooms in meetings at once, three of them sharing content, need about 38.2 Mbit/s up and 78.7 Mbit/s down.
  5. With 20% headroom a 100/20 link is too small, mainly on the upload side, which is the half people forget: every room sends its own video. Zoom publishes its own figures in the same range (for example 1.2 Mbit/s for 720p in one-to-one calls, and 2.6/1.8 Mbit/s up/down for 720p and 3.8/3.0 Mbit/s for 1080p in group calls, on its "Zoom system requirements" page). Real usage depends on layouts, content and how many rooms are busy at once; Microsoft's Network Planner in the Teams admin center does this calculation per site.
AdvancedWebRTC's statistics, for comparison

Browsers expose the same ideas through RTCPeerConnection.getStats(), as the loopback run in WebRTC concepts showed: outbound-rtp (packets and bytes sent, frames encoded, targetBitrate, qualityLimitationReason such as cpu or bandwidth), inbound-rtp (packets received and lost, jitter in seconds, jitterBufferDelay, frames decoded and dropped, freezeCount), remote-inbound-rtp (what the far end reported back over RTCP: its view of your loss, jitter and round trip) and candidate-pair (the selected path, currentRoundTripTime, availableOutgoingBitrate). Most counters are cumulative, so a UI samples them every second or two and shows the differences: bytes per second, packets lost per interval.

AdvancedGotcha: averages hide the spikes
High delay, zero jitter

A room's call statistics show jitter of 1 ms, no packet loss, but people complain of a long delay and keep talking over each other. Round-trip time is 400 ms. What's going on, and what would you check?

Show answer

The path is smooth but long or heavily buffered: a latency problem, not a jitter problem.

What’s happening
  1. Jitter measures variation in transit time, as the RFC 3550 model showed: a constant delay, however large, gives jitter near 0 ms. So 1 ms of jitter says nothing about how long the trip takes.
  2. 400 ms round trip is about 200 ms each way before encoding, buffering and decoding are added. Microsoft's target from a client to its edge is under 100 ms round trip, so this path is four times over it.
  3. The common causes are geography or routing (media hairpinning through a far-away VPN concentrator, relay or data centre), a VPN that wasn't split-tunnelled for media, or a queue somewhere that's always full (bufferbloat), which raises delay without raising jitter, like the ramp in the model.
  4. I'd check which path the media takes (direct, relayed, through a VPN), the round-trip time to the nearest media server or edge, and whether the delay grows under load. The fix is routing (split-tunnel the VPN, use a nearer relay or edge), not a bigger jitter buffer, which would only add more delay.

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.