Peer Connections and SDP
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
The RTCPeerConnection is the object that turns a set of tracks and data channels into an encrypted, congestion-controlled
connection between two browsers (or a browser and a server). It is the heart of the Huddle videoconference, and of any
Pixel Arena variant that moves game state over data channels. This page explains its configuration, the offer/answer
exchange defined by JSEP, how to read the Session Description Protocol (SDP) that the browser produces, the transceiver model,
and the three state machines that tell you what the connection is doing.
Baseline: the W3C WebRTC 1.0 Recommendation (13 March 2025 edition), JSEP in RFC 9429 (which obsoletes RFC 8829), SDP in RFC 8866 (which obsoletes RFC 4566), BUNDLE in RFC 9143 (which obsoletes RFC 8843), and the examples run in Chrome 155. All of these were checked on 2026-10-24. The page does not depend on adapter.js, whose version 9 removed almost all of its shims.
What an RTCPeerConnection does
A peer connection is a bundle of five cooperating subsystems, and knowing which one owns a symptom makes debugging far faster.
-
The signaling state machine tracks the offer/answer exchange. It is purely local: nothing is sent anywhere until your code relays the descriptions over a channel of its own (see Signaling and Perfect Negotiation).
-
The ICE agent gathers candidate addresses and tests pairs of them to find a path, see ICE, STUN and TURN.
-
The DTLS transport runs a handshake over the selected ICE path and derives keys for SRTP and for the SCTP association that carries data channels. Media is therefore always encrypted; there is no way to switch DTLS-SRTP off.
-
The RTP stack packetizes encoded audio and video, applies retransmission (NACK, RTX), forward error correction and congestion control, and reports statistics, see Stats, Quality and Bandwidth.
-
The SCTP stack carries data channels, see Data Channels.
Huddle uses all five: one peer connection per remote participant in mesh mode, a single send connection and a single receive connection per client in SFU mode, see Topologies: Mesh, SFU and MCU. Pixel Arena uses only the ICE, DTLS and SCTP parts when it opens an unreliable data channel for input and snapshots. The overall picture is in WebRTC Architecture, and the tracks that you add to the connection come from Media Capture and Devices.
Configuring the connection
The constructor takes an RTCConfiguration dictionary. Every member can be changed later with setConfiguration(), except
the ones the specification marks as immutable after the first description is applied (bundlePolicy, rtcpMuxPolicy,
iceCandidatePoolSize and certificates).
| Member | Meaning | Default and advice |
|---|---|---|
|
List of STUN and TURN servers, each with |
Empty. Always give at least a STUN server and, for production, a TURN server whose credentials are short-lived. |
|
|
|
|
How media sections share transports: |
|
|
Only |
|
|
Number of candidates to pre-gather before a description exists. |
0. A small value (1 to 2) shortens call set-up when you know a call is coming, at the cost of keeping sockets open. |
|
Pre-generated DTLS certificates ( |
Generated on demand. Supply them only if you want a stable fingerprint across connections. |
const config = {
iceServers: [
{ urls: 'stun:stun.example.org:3478' },
// Ephemeral credentials come from your own endpoint, never from a constant in the bundle.
{ urls: ['turn:turn.example.org:3478?transport=udp', 'turns:turn.example.org:443?transport=tcp'],
username: '1767225600:ana', credential: 'issued-by-your-server' },
],
bundlePolicy: 'max-bundle',
iceTransportPolicy: 'all',
};
const pc = new RTCPeerConnection(config);
JSEP: the offer/answer model
WebRTC does not define how two peers find each other or exchange messages. It defines the JavaScript Session Establishment Protocol (JSEP, RFC 9429), in which the application is responsible for moving session descriptions between the peers while the browser is responsible for generating and interpreting them. The model is the offer/answer exchange of RFC 3264, specialised for ICE, DTLS and bundled media.
The caller builds an offer, applies it as its local description, and sends it. The callee applies the received offer as its remote description, builds an answer, applies that as its local description, and sends it back. The caller applies the answer as its remote description. Four calls are involved, and the order matters because each one moves the signaling state machine:
Three details deserve emphasis.
-
Implicit descriptions. Calling
setLocalDescription()with no argument creates the right description for the current state itself: an offer when the state isstableorhave-local-offer, an answer when it ishave-remote-offer. Prefer it tocreateOffer()followed bysetLocalDescription(offer), because it avoids the race in which the state changes between the two calls. After it resolves,pc.localDescriptionholds the description to send. -
Nothing is sent by the browser. A description is a plain object with
type("offer","answer","pranswer"or"rollback") andsdp. In JSON it is exactly what the Huddle signaling message carries. -
Candidates are separate. With trickle ICE (RFC 8840) the description is sent as soon as it exists and candidates follow as the
icecandidateevent fires. Thenullcandidate at the end of gathering is passed toaddIceCandidate()as is, or skipped; both are fine.
The answer may only use what the offer proposed: a subset of the codecs, a direction compatible with the offered one, and the same set of media sections in the same order. This is why an offer lists every codec the browser supports, and why the answer in the example below is so much shorter.
Pranswer, rollback and glare
A provisional answer ("pranswer") lets a callee start media before the final answer. It exists mostly for legacy gateways and
is rarely used in browser-to-browser calls. Rollback matters much more: calling
setLocalDescription({ type: 'rollback' }) in the have-local-offer state returns the connection to stable and discards the
offer, and the same happens in have-remote-offer. Rollback is how the polite peer of the perfect-negotiation pattern
gives way when both sides send an offer at the same time (glare). The pattern is developed in
Signaling and Perfect Negotiation. Applying a remote offer while in
have-local-offer performs an implicit rollback according to the specification, but writing the rollback explicitly makes the intent
clear and is what the examples below do.
Anatomy of a real SDP offer
SDP (RFC 8866) is a line-oriented text format: each line is a single letter, an equals sign and a value. The blocks below are a real offer produced by Chrome 155 for a Huddle participant who publishes a microphone, a camera and one data channel. It was captured by the script in the next section, then shortened for the page: the video section lists only VP8, VP9 and their retransmission payloads (the browser actually offered 27 video payload types, including H264, AV1 and H265). Nothing else was edited, so the identifiers, credentials and fingerprint are the ones the browser generated for that single throw-away session.
Session level
v=0
o=- 4480330054849832710 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
a=extmap-allow-mixed
a=msid-semantic: WMS 75afa92e-fda8-462f-b840-0fbbd2a2fa9b
-
v=0is the only SDP version that exists. -
o=is the origin: a username (-means none), a session id and a version counter that increases with each new offer in the same connection, then the network type, address type and an address that is always loopback in WebRTC. -
s=-is an empty session name andt=0 0means the session has no start or stop time. -
a=group:BUNDLE 0 1 2says that the media sections whosemidvalues are 0, 1 and 2 share one transport. The three sections carry audio, video and the data channel over a single ICE and DTLS connection (RFC 9143). -
a=extmap-allow-mixedallows one-byte and two-byte RTP header-extension headers in the same stream (RFC 8285). -
a=msid-semantic: WMSis a legacy marker for the media-stream identifiers used in thea=msidlines. Modern parsers ignore it, but older ones need it.
The audio section
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:twew
a=ice-pwd:4xRTBGJQ3lT1yPg/GRZo/BFG
a=ice-options:trickle
a=fingerprint:sha-256 98:23:6E:0F:8A:81:A5:DD:2D:7E:14:A8:EE:62:F6:16:80:32:4E:0E:D7:A4:EB:EA:25:47:67:4D:1C:02:70:43
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:75afa92e-fda8-462f-b840-0fbbd2a2fa9b ff4bd041-bdc7-4837-92c9-57e13b02c1e4
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:1836211315 cname:1KzwyFQNWvh//8D2
a=ssrc:1836211315 msid:75afa92e-fda8-462f-b840-0fbbd2a2fa9b ff4bd041-bdc7-4837-92c9-57e13b02c1e4
-
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126starts a media section: the kind, a placeholder port (9, the discard port), the transport (RTP over DTLS-SRTP with feedback) and the list of payload types in order of preference. The first one, 111, is Opus. Because candidates are trickled, the connection address is the placeholderc=IN IP4 0.0.0.0. -
a=rtcp:9 IN IP4 0.0.0.0is the matching placeholder for RTCP. -
a=ice-ufraganda=ice-pwdare the ICE username fragment and password of the offerer. They are the credentials of the STUN connectivity checks, not a secret that protects media, and they are new for each ICE restart. They are not a media key.a=ice-options:trickleannounces that candidates may follow the description. -
a=fingerprint:sha-256is the hash of the offerer’s DTLS certificate. The DTLS handshake is authenticated against it, so whoever relays the description over signaling could, by replacing it, mount a man-in-the-middle attack. This is why the signaling channel itself must be authenticated and encrypted (TLS), see Security. -
a=setup:actpassoffers to take either DTLS role. The answerer chooses; in practice it answersactiveand so becomes the DTLS client, as the answer shown later does. -
a=mid:0is the media identification tag. It is how the two sides refer to this section, and it is carried in RTP header extensions so that an incoming stream can be matched to its section before the signaling has caught up. -
a=extmap:N urinegotiates RTP header extensions: audio level (urn:ietf:params:rtp-hdrext:ssrc-audio-level, the source of Huddle’s active-speaker indicator on the SFU), absolute send time, transport-wide congestion control sequence numbers and themid. -
a=sendrecvis the direction. It is what the transceiver direction becomes in SDP. -
a=msid:<stream id> <track id>ties the section to theMediaStreamandMediaStreamTrackthe sender used. It is what fillsevent.streamson the remotetrackevent. -
a=rtcp-muxmeans RTP and RTCP share the same port;a=rtcp-rsizeallows reduced-size RTCP;a=rtcp-xr:rcvr-rtt=allasks for receiver round-trip time reports. -
a=rtpmap:111 opus/48000/2binds payload type 111 to the Opus codec at a 48 kHz clock with two channels, anda=fmtp:111 minptime=10;useinbandfec=1sets format parameters: the minimum packet time and Opus in-band forward error correction.a=rtcp-fb:111 transport-ccenables transport-wide congestion control feedback. -
Payload 63 is RED (redundant audio) wrapping Opus, 9 is G.722, 0 and 8 are PCMU and PCMA, 13 is comfort noise and 110 and 126 carry DTMF telephone events. Payload types below 96 are static assignments, while 96 and above are dynamic and change from browser to browser, which is why you must always look codecs up by name, never by number.
-
a=ssrc:<id> cname:...anda=ssrc:<id> msid:...announce the synchronisation source that the sender will use and the canonical name that ties its audio and video together for lip sync. A server that receives an SFU stream can also use the SSRC to route packets.
The video section
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:twew
a=ice-pwd:4xRTBGJQ3lT1yPg/GRZo/BFG
a=ice-options:trickle
a=fingerprint:sha-256 98:23:6E:0F:8A:81:A5:DD:2D:7E:14:A8:EE:62:F6:16:80:32:4E:0E:D7:A4:EB:EA:25:47:67:4D:1C:02:70:43
a=setup:actpass
a=mid:1
a=extmap:14 urn:ietf:params:rtp-hdrext:toffset
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:13 urn:3gpp:video-orientation
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay
a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type
a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing
a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space
a=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid
a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id
a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id
a=sendrecv
a=msid:75afa92e-fda8-462f-b840-0fbbd2a2fa9b 2ca4d4c6-db93-4c2c-a494-ca6ec039a398
a=rtcp-mux
a=rtcp-rsize
a=rtcp-xr:rcvr-rtt=all
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:98 VP9/90000
a=rtcp-fb:98 goog-remb
a=rtcp-fb:98 transport-cc
a=rtcp-fb:98 ccm fir
a=rtcp-fb:98 nack
a=rtcp-fb:98 nack pli
a=fmtp:98 profile-id=0
a=rtpmap:99 rtx/90000
a=fmtp:99 apt=98
a=ssrc-group:FID 73460372 2682249460
a=ssrc:73460372 cname:1KzwyFQNWvh//8D2
a=ssrc:73460372 msid:75afa92e-fda8-462f-b840-0fbbd2a2fa9b 2ca4d4c6-db93-4c2c-a494-ca6ec039a398
a=ssrc:2682249460 cname:1KzwyFQNWvh//8D2
a=ssrc:2682249460 msid:75afa92e-fda8-462f-b840-0fbbd2a2fa9b 2ca4d4c6-db93-4c2c-a494-ca6ec039a398
The video section repeats the transport attributes because each section is self-contained, but with BUNDLE and identical
ICE credentials and fingerprint all three sections run over one transport. Differences worth reading:
-
a=mid:1and a longer list of header extensions: video orientation (urn:3gpp:video-orientation), playout delay, content type, timing, colour space, and the RTP stream id extensions that simulcast uses, see Codecs, Simulcast and SVC. -
a=rtpmap:96 VP8/90000uses the 90 kHz clock that every RTP video codec shares.a=rtpmap:97 rtx/90000witha=fmtp:97 apt=96is the retransmission payload: it carries re-sent copies of payload 96 packets, asapt(associated payload type) says. The VP9 pair follows the same pattern, anda=fmtp:98 profile-id=0selects VP9 profile 0. -
The
a=rtcp-fblines list the feedback mechanisms the receiver supports:nack(ask for a lost packet),nack pli(ask for a new key frame after loss),ccm fir(full intra request),goog-remb(the older Google bandwidth estimate) andtransport-cc(the newer one). This is where Huddle’s recovery from a packet burst comes from. -
a=ssrc-group:FID 73460372 2682249460ties the media SSRC to its retransmission SSRC. Without simulcast there is one such pair per video source.
The data section
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:twew
a=ice-pwd:4xRTBGJQ3lT1yPg/GRZo/BFG
a=ice-options:trickle
a=fingerprint:sha-256 98:23:6E:0F:8A:81:A5:DD:2D:7E:14:A8:EE:62:F6:16:80:32:4E:0E:D7:A4:EB:EA:25:47:67:4D:1C:02:70:43
a=setup:actpass
a=mid:2
a=sctp-port:5000
a=max-message-size:262144
m=application ... UDP/DTLS/SCTP webrtc-datachannel is the data section (RFC 8841). It has no codecs: a=sctp-port:5000
is the SCTP port inside the DTLS tunnel and a=max-message-size:262144 is the largest message the sender will accept, 256 KiB.
Individual channels are not listed in SDP. They are opened in-band with the data channel establishment protocol, so adding a
second channel after the first does not cause a new negotiation.
The answer
The answer produced by a second Chrome instance for this offer (with the receiver restricted to VP8, see below) is a strict subset. The extension, feedback and format-parameter lines are omitted here for space; the remaining lines are unchanged.
v=0
o=- 81094165864880339 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1 2
a=msid-semantic: WMS
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 13 110 126
c=IN IP4 0.0.0.0
a=ice-ufrag:Exr7
a=setup:active
a=mid:0
a=recvonly
a=rtcp-mux
a=rtpmap:111 opus/48000/2
m=video 9 UDP/TLS/RTP/SAVPF 96 97
c=IN IP4 0.0.0.0
a=ice-ufrag:Exr7
a=setup:active
a=mid:1
a=recvonly
a=rtcp-mux
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:Exr7
a=setup:active
a=mid:2
a=sctp-port:5000
a=max-message-size:262144
The same BUNDLE group and the same mid values come back, the answerer has its own ICE credentials and fingerprint, it
selected a=setup:active, the direction turned from sendrecv into recvonly because the answerer added no tracks, and the
video payload list shrank to the codecs the answerer was willing to receive. The answerer’s msid-semantic is empty because it
sends nothing.
Capturing an offer yourself
Reading a real description is the quickest way to learn SDP, and the browser produces one in a few lines. This script is the
one used for the blocks above. Run it in a page served from http://localhost or HTTPS, because getUserMedia needs a secure
context; in headless Chrome the flags --use-fake-device-for-media-stream and --use-fake-ui-for-media-stream supply a
synthetic camera and microphone and skip the permission prompt.
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.example.org:3478' }] });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 640, height: 360 } });
for (const track of stream.getTracks()) pc.addTrack(track, stream);
pc.createDataChannel('chat', { ordered: true });
await pc.setLocalDescription();
console.log(pc.localDescription.type);
console.log(pc.localDescription.sdp);
pc.close();
window.__done = true;
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new \
--use-fake-device-for-media-stream --use-fake-ui-for-media-stream \
--remote-debugging-port=9222 http://localhost:8080/capture.html
Transceivers: the unit of negotiation
An RTCRtpTransceiver pairs one RTCRtpSender with one RTCRtpReceiver and owns exactly one media section of the SDP. Its
mid is null until a description has been applied, and then equals the a=mid value. Because media sections can never be
removed, only rejected, understanding transceivers is the key to understanding why a session description keeps growing.
| API | Behaviour |
|---|---|
|
Reuses a compatible idle transceiver (same kind, no sender track, never used to send) or creates one with direction
|
|
Always creates a new transceiver. The |
|
Your preference: |
|
What was negotiated. It is |
|
Swaps the source (camera for a screen capture, or |
|
Changes encoding parameters such as |
|
Reorders or restricts the codecs offered or answered for that section. The next offer or answer is generated from it. |
|
Permanently stops the transceiver. The next offer sets its |
Direction is a source of mistakes, so keep this mapping in mind. The offerer’s sendonly becomes recvonly in the answer;
recvonly becomes sendonly if the answerer has a track, otherwise inactive; sendrecv stays sendrecv only if the
answerer also has a track, otherwise it becomes recvonly. The receiver of a remote sendonly section always gets a track
event even if no media has arrived yet, and the track.muted property tells you whether packets are actually flowing.
Huddle uses addTrack for the microphone and camera, replaceTrack when the user switches devices or starts a screen
share (see Screen Sharing), and track.enabled = false for the mute and
camera buttons. Setting enabled to false keeps the sender and its track, makes the browser send silence or black frames,
and needs no renegotiation. Stopping the track with track.stop() instead releases the device and its indicator light, but the
sender then has nothing to send until you call replaceTrack with a new track.
Codec preferences
By default the answerer accepts the offerer’s preferred codec that it supports. setCodecPreferences() lets either side change
that choice. Pass entries taken from RTCRtpSender.getCapabilities(kind).codecs or RTCRtpReceiver.getCapabilities(kind).codecs,
reordered, and keep the auxiliary entries (rtx, red, ulpfec) in the list, because dropping rtx silently disables
retransmission. Passing an empty array resets the transceiver to the browser default. Calling it on a transceiver whose
capabilities do not contain a codec throws an InvalidModificationError. The practical use in Huddle is to prefer VP9 or AV1
for screen content and H264 for devices that have hardware encoders, as discussed in
Codecs, Simulcast and SVC and, for how the codecs themselves work,
video encoding fundamentals.
The negotiationneeded event
The browser raises negotiationneeded when the local description no longer describes what the application wants, for example
after addTrack, after a direction change, or after the first createDataChannel(). The event is coalesced: several calls in
the same task produce one event, which is why the example below negotiates once for an audio track and a video transceiver. It
is never raised while the signaling state is not stable; the browser waits and raises it again on return to stable if the
need persists.
A reliable handler therefore does the whole offer round trip, and protects itself against re-entry (the makingOffer flag of
perfect negotiation). Adding a track or a channel before the first negotiation is not an error, it just means the event fires
once at start-up.
Source: MDN: negotiationneeded event
The three state machines
A peer connection has three independent state attributes, each with its own event, and a fourth that reports ICE gathering.
They answer different questions, so Huddle’s UI watches connectionState while the signaling code watches signalingState.
| Attribute and event | Values | Meaning |
|---|---|---|
|
|
Position in the offer/answer exchange. New transitions only come from |
|
|
Whether the ICE agent is still collecting candidates. It starts when a description is applied. |
|
|
Health of the ICE transport only. |
|
|
Aggregate of ICE and DTLS across all transports. It reaches |
The log below is the actual output of the example in the next section. connectionState reaches connected a few
milliseconds after iceConnectionState, which is the DTLS handshake.
alice negotiationneeded #1
alice signalingState -> have-local-offer
bob signalingState -> have-remote-offer
bob track event: audio mid 0 streams 1
bob track event: video mid 1 streams 1
bob signalingState -> stable
alice signalingState -> stable
bob iceConnectionState -> checking
alice iceConnectionState -> checking
bob iceConnectionState -> connected
alice iceConnectionState -> connected
bob connectionState -> connected
alice connectionState -> connected
Practical guidance on the states:
-
Treat
disconnectedas a warning, not an error. Network changes (Wi-Fi to cellular, a laptop waking up) commonly pass through it and return toconnectedby themselves within seconds. Show a "reconnecting" badge, and start a timer. -
Treat
failedas final for the current candidates. Callpc.restartIce(), then send a new offer, which thenegotiationneededevent will trigger. If it fails again, tear the connection down and rebuild it from scratch. -
closedis only ever reached by your ownclose()call, and the object is then unusable. -
Do not rely on
completed: it is reported only by some browsers andconnectedis enough to start the call. -
connectionStateandiceConnectionStatecan disagree. If the DTLS fingerprint check fails,connectionStatebecomesfailedwhile ICE saysconnected.
The track event
When a remote description adds or changes a media section, the receiving side fires track with the RTCRtpReceiver, the
MediaStreamTrack, the RTCRtpTransceiver and the array of MediaStream objects from the msid. In Huddle the handler
attaches streams[0] to a <video> element with autoplay and playsinline; if the sender did not set a stream, streams
is empty and you must create your own MediaStream and add the track to it. The track is muted until the first packet
arrives, and the unmute event is the reliable signal to remove the placeholder avatar.
Source: MDN: track event
A complete Huddle negotiation
The next script puts two peers in one page, with the signaling channel replaced by direct function calls. It negotiates a
microphone and a camera, applies codec preferences, swaps a track, demonstrates mute without renegotiation, resolves a
simulated glare with a rollback, and closes everything cleanly. In a real Huddle, every call that crosses between alice
and bob becomes a signaling message, see
Signaling and Perfect Negotiation.
// Huddle: two peers in one page, signalling replaced by direct function calls.
const log = (...a) => console.log(...a);
const config = { iceServers: [{ urls: 'stun:stun.example.org:3478' }], bundlePolicy: 'max-bundle' };
function watch(name, pc) {
pc.addEventListener('signalingstatechange', () => log(name, 'signalingState ->', pc.signalingState));
pc.addEventListener('iceconnectionstatechange', () => log(name, 'iceConnectionState ->', pc.iceConnectionState));
pc.addEventListener('connectionstatechange', () => log(name, 'connectionState ->', pc.connectionState));
pc.addEventListener('icegatheringstatechange', () => log(name, 'iceGatheringState ->', pc.iceGatheringState));
}
const alice = new RTCPeerConnection(config);
const bob = new RTCPeerConnection(config);
watch('alice', alice);
watch('bob', bob);
// Trickle ICE: each candidate goes straight to the other side (a real app sends it over the signalling channel).
alice.onicecandidate = ({ candidate }) => bob.addIceCandidate(candidate).catch((e) => log('bob addIceCandidate', e.name));
bob.onicecandidate = ({ candidate }) => alice.addIceCandidate(candidate).catch((e) => log('alice addIceCandidate', e.name));
const remoteKinds = [];
bob.ontrack = ({ track, transceiver, streams }) => {
remoteKinds.push(track.kind);
log('bob track event:', track.kind, 'mid', transceiver.mid, 'streams', streams.length);
};
let negotiations = 0;
alice.onnegotiationneeded = async () => {
negotiations += 1;
log('alice negotiationneeded #' + negotiations);
await alice.setLocalDescription(); // implicit createOffer
await bob.setRemoteDescription(alice.localDescription);
await bob.setLocalDescription(); // implicit createAnswer
await alice.setRemoteDescription(bob.localDescription);
};
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 640, height: 360 } });
const [audio, video] = [stream.getAudioTracks()[0], stream.getVideoTracks()[0]];
const audioSender = alice.addTrack(audio, stream);
const videoTransceiver = alice.addTransceiver(video, { direction: 'sendrecv', streams: [stream] });
// Prefer VP9 and AV1 for the camera, keep rtx/red/ulpfec at the end.
const caps = RTCRtpSender.getCapabilities('video').codecs;
const rank = (c) => (/vp9/i.test(c.mimeType) ? 0 : /av1/i.test(c.mimeType) ? 1 : /vp8/i.test(c.mimeType) ? 2 : /h264/i.test(c.mimeType) ? 3 : 4);
videoTransceiver.setCodecPreferences([...caps].sort((a, b) => rank(a) - rank(b)));
const until = (pc, prop, value) => new Promise((resolve) => {
const check = () => { if (pc[prop] === value) { resolve(); return true; } return false; };
if (!check()) { const id = setInterval(() => check() && clearInterval(id), 20); }
});
await Promise.all([until(alice, 'connectionState', 'connected'), until(bob, 'connectionState', 'connected')]);
log('alice transceivers:', alice.getTransceivers().map((t) => `${t.mid}:${t.sender.track?.kind}:${t.direction}/${t.currentDirection}`).join(' '));
const stats = await alice.getStats();
const codecs = [...stats.values()].filter((s) => s.type === 'codec' && s.mimeType.startsWith('video')).map((s) => s.mimeType);
log('negotiated video codec:', codecs.join(','));
// replaceTrack: swap the camera for a canvas stream without renegotiating.
const canvas = Object.assign(document.createElement('canvas'), { width: 320, height: 180 });
canvas.getContext('2d').fillRect(0, 0, 320, 180);
const slides = canvas.captureStream(5).getVideoTracks()[0];
const before = negotiations;
await videoTransceiver.sender.replaceTrack(slides);
await new Promise((r) => setTimeout(r, 300));
log('replaceTrack renegotiations:', negotiations - before);
// Mute without renegotiation: enabled=false sends silence/black frames, track stays.
audio.enabled = false;
log('audio.enabled', audio.enabled, 'sender track still', audioSender.track === audio);
// Rollback: alice offers, then bob also offers (glare); alice rolls back and takes bob's offer.
const polite = new RTCPeerConnection(config);
const impolite = new RTCPeerConnection(config);
polite.addTransceiver('video');
impolite.addTransceiver('audio');
await polite.setLocalDescription();
await impolite.setLocalDescription();
log('polite state before rollback:', polite.signalingState);
await polite.setLocalDescription({ type: 'rollback' });
log('polite state after rollback:', polite.signalingState);
await polite.setRemoteDescription(impolite.localDescription);
await polite.setLocalDescription();
await impolite.setRemoteDescription(polite.localDescription);
log('glare resolved:', polite.signalingState, impolite.signalingState);
// Close cleanly.
for (const pc of [alice, bob, polite, impolite]) pc.close();
stream.getTracks().forEach((t) => t.stop());
log('closed:', alice.connectionState, alice.signalingState, bob.connectionState);
window.__done = true;
The script ends with this output in headless Chrome 155 with fake devices (excerpt, beginning from the connected state):
alice transceivers: 0:audio:sendrecv/sendonly 1:video:sendrecv/sendonly
negotiated video codec: video/VP9
replaceTrack renegotiations: 0
audio.enabled false sender track still true
polite state before rollback: have-local-offer
polite state after rollback: stable
glare resolved: stable stable
closed: closed closed closed
Note that direction is still sendrecv while currentDirection is sendonly: Bob added no tracks, so his answer turned the
sections into recvonly. Note also that replaceTrack renegotiated nothing, and that the first negotiationneeded event
covered both addTrack and addTransceiver.
Restarting ICE and removing media
Networks change under a long call. restartIce() marks the connection so that the next offer carries new ICE credentials,
gathers fresh candidates and re-runs the checks, without touching the DTLS session or the media tracks. The script below was
run against the same loopback pair and confirms that it raises negotiationneeded, changes the ice-ufrag, and that stopping a
transceiver sets its m= port to 0 and drops it from the list.
const config = { bundlePolicy: 'max-bundle' };
const a = new RTCPeerConnection(config);
const b = new RTCPeerConnection(config);
a.onicecandidate = ({ candidate }) => b.addIceCandidate(candidate);
b.onicecandidate = ({ candidate }) => a.addIceCandidate(candidate);
async function negotiate(from, to) {
await from.setLocalDescription();
await to.setRemoteDescription(from.localDescription);
await to.setLocalDescription();
await from.setRemoteDescription(to.localDescription);
}
const ufrag = (pc) => /a=ice-ufrag:(\S+)/.exec(pc.localDescription.sdp)[1];
const connected = (pc) => new Promise((ok) => {
if (pc.connectionState === 'connected') return ok();
pc.addEventListener('connectionstatechange', () => pc.connectionState === 'connected' && ok());
});
a.addTransceiver('audio', { direction: 'sendrecv' });
const video = a.addTransceiver('video', { direction: 'sendonly' });
let needed = 0;
a.onnegotiationneeded = () => { needed += 1; };
await negotiate(a, b);
await Promise.all([connected(a), connected(b)]);
const first = ufrag(a);
a.restartIce(); // schedules negotiationneeded; the next offer carries fresh credentials
await new Promise((r) => setTimeout(r, 50));
console.log('negotiationneeded fired after restartIce:', needed >= 1);
await negotiate(a, b);
console.log('ufrag changed:', first !== ufrag(a));
video.stop(); // the m= section is rejected in the next offer
await negotiate(a, b);
const videoSection = a.localDescription.sdp.split('m=').find((s) => s.startsWith('video'));
console.log('video m= port after stop:', videoSection.split(' ')[1], 'currentDirection:', video.currentDirection);
console.log('transceivers left:', a.getTransceivers().length);
a.close(); b.close();
window.__done = true;
Output:
negotiationneeded fired after restartIce: true
ufrag changed: true
video m= port after stop: 0 currentDirection: stopped
transceivers left: 1
In production an ICE restart is triggered by iceConnectionState === 'failed', by a timer after disconnected, or by the
online event of the browser. Only the offerer should start it, or both sides will; in perfect negotiation the polite peer
tolerates a collision automatically. See ICE, STUN and TURN for what a
restart changes in the candidate list.
Closing cleanly
pc.close() stops all transceivers, closes data channels, releases the ICE and DTLS transports, and sets signalingState and
connectionState to closed. It does not fire connectionstatechange for the local side, so update your UI explicitly. It also
does not stop the capture tracks: call stream.getTracks().forEach((t) => t.stop()) to release the camera and microphone,
otherwise the browser keeps the device indicator on. In Huddle, closing a tab or navigating away does not tell the other
participants anything quickly, because the DTLS close alert is best effort; the signaling server should therefore emit a
peer-left message when the WebSocket closes, and clients should also watch connectionState for failed.
Remove event listeners and set ontrack, onicecandidate and similar handlers to null when discarding a connection that your
framework might otherwise keep alive. A peer connection that is dropped without close() keeps its sockets and any TURN
allocation until the browser collects it, so always close it explicitly.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
The description type does not fit the signaling state, for example an answer when the state is |
An error about the order of m-lines |
The new description changed the order or number of media sections. Sections can only be appended or rejected, never reordered, so rebuild the offer from the same connection and never hand-edit the SDP. |
|
The answer proposed a codec, header extension or fingerprint that the offer did not contain. Always take the answer from
|
Offers collide or a description is rejected while another negotiation is in flight |
Calling |
No audio or video but |
The track event fired but the stream was not attached, or autoplay policy blocked playback of an element without
|
Remote video is black or frozen |
The sender’s track was stopped or |
Works on LAN, fails across networks |
No TURN configured, or TURN credentials expired. See ICE, STUN and TURN. |
Candidates added too early |
|
Codec unexpectedly H264 or VP8 |
The answerer’s list did not contain your preferred codec, or |
Security considerations
-
Authenticate signaling. The DTLS certificate fingerprint travels inside the description, so a signaling server or middlebox that can alter it can intercept media. Use TLS for the signaling WebSocket, authenticate every participant before accepting a join, and authorise who may relay to whom (see Authentication and Authorization).
-
IP exposure. Host candidates reveal local addresses, and server-reflexive ones reveal the public address, to every peer. Browsers replace local addresses with mDNS names until the page has been granted capture permission (mDNS candidates are specified in an Internet-Draft, not an RFC);
iceTransportPolicy: 'relay'hides everything but the relay. -
Treat remote SDP as input. Descriptions are parsed by the browser, but the signaling server may also parse them (to limit bandwidth or codecs). Never evaluate or interpolate them into HTML, and cap their size.
-
DTLS-SRTP is mandatory. The audio and video in Huddle are encrypted between browsers, but an SFU terminates the encryption and re-encrypts, see End-to-End Encryption.
-
Permissions. A peer connection itself needs no permission, but capturing the camera does, see Media Capture and Devices.
Version and specification history
-
RFC 8829 (January 2021) published JSEP and RFC 9429 (April 2024) obsoletes it. RFC 8866 (January 2021) obsoletes RFC 4566 for SDP, and RFC 9143 obsoletes RFC 8843 for BUNDLE. Cite the current ones.
-
Promises replaced the original callback style of
createOfferandsetLocalDescription, and the implicit no-argumentsetLocalDescription()and the perfect-negotiation pattern were added to the specification afterwards. Current Chrome, Firefox and Safari support them. -
The unified plan (one media section per transceiver) is the only SDP semantics in current browsers; the older plan B (several tracks in one section) is obsolete, so code that sets or tests
sdpSemanticscan be removed. -
a=extmapURIs such as the transport-wide congestion control extension and thegoog-rembfeedback are widely deployed but are documented only in expired Internet-Drafts, as thedraft-holmerURI in the offer shows. RFC 8888 is the standardised congestion feedback. -
The W3C WebRTC 1.0 specification became a Recommendation on 13 March 2025, and the working group continues the next level as a working draft.
Next steps
Continue with Signaling and Perfect Negotiation, which moves the descriptions over a WebSocket and resolves glare, and with ICE, STUN and TURN for the connectivity half of the story. For data channels, see Data Channels and the existing WebRTC data channels in conversational channels.