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

iceServers

List of STUN and TURN servers, each with urls, and for TURN a username and credential.

Empty. Always give at least a STUN server and, for production, a TURN server whose credentials are short-lived.

iceTransportPolicy

"all" uses every candidate type, "relay" uses only TURN relay candidates.

"all". Use "relay" to hide participants' addresses from each other, at the price of relay bandwidth.

bundlePolicy

How media sections share transports: "balanced", "max-compat" or "max-bundle".

"balanced". Use "max-bundle" so that audio, video and data share one ICE and DTLS transport and need only one port pair.

rtcpMuxPolicy

Only "require" is defined.

"require": RTP and RTCP share a port, see RFC 5761.

iceCandidatePoolSize

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.

certificates

Pre-generated DTLS certificates (RTCPeerConnection.generateCertificate()).

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:

sequenceDiagram participant A as Ana (caller) participant S as Signaling server participant B as Bruno (callee) Note over A: signalingState stable A->>A: addTrack() fires negotiationneeded A->>A: setLocalDescription() creates and applies an offer Note over A: have-local-offer, ICE gathering starts A->>S: offer (type and sdp) S->>B: offer B->>B: setRemoteDescription(offer) Note over B: have-remote-offer, track events fire B->>B: setLocalDescription() creates and applies an answer Note over B: stable, ICE gathering starts B->>S: answer S->>A: answer A->>A: setRemoteDescription(answer) Note over A: stable A-->>B: trickled candidates relayed through the server B-->>A: trickled candidates relayed through the server Note over A,B: ICE checks then DTLS handshake then media flows

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 is stable or have-local-offer, an answer when it is have-remote-offer. Prefer it to createOffer() followed by setLocalDescription(offer), because it avoids the race in which the state changes between the two calls. After it resolves, pc.localDescription holds the description to send.

  • Nothing is sent by the browser. A description is a plain object with type ("offer", "answer", "pranswer" or "rollback") and sdp. 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 icecandidate event fires. The null candidate at the end of gathering is passed to addIceCandidate() 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=0 is 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 and t=0 0 means the session has no start or stop time.

  • a=group:BUNDLE 0 1 2 says that the media sections whose mid values 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-mixed allows one-byte and two-byte RTP header-extension headers in the same stream (RFC 8285).

  • a=msid-semantic: WMS is a legacy marker for the media-stream identifiers used in the a=msid lines. 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 126 starts 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 placeholder c=IN IP4 0.0.0.0.

  • a=rtcp:9 IN IP4 0.0.0.0 is the matching placeholder for RTCP.

  • a=ice-ufrag and a=ice-pwd are 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:trickle announces that candidates may follow the description.

  • a=fingerprint:sha-256 is 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:actpass offers to take either DTLS role. The answerer chooses; in practice it answers active and so becomes the DTLS client, as the answer shown later does.

  • a=mid:0 is 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 uri negotiates 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 the mid.

  • a=sendrecv is the direction. It is what the transceiver direction becomes in SDP.

  • a=msid:<stream id> <track id> ties the section to the MediaStream and MediaStreamTrack the sender used. It is what fills event.streams on the remote track event.

  • a=rtcp-mux means RTP and RTCP share the same port; a=rtcp-rsize allows reduced-size RTCP; a=rtcp-xr:rcvr-rtt=all asks for receiver round-trip time reports.

  • a=rtpmap:111 opus/48000/2 binds payload type 111 to the Opus codec at a 48 kHz clock with two channels, and a=fmtp:111 minptime=10;useinbandfec=1 sets format parameters: the minimum packet time and Opus in-band forward error correction. a=rtcp-fb:111 transport-cc enables 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:... and a=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:1 and 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/90000 uses the 90 kHz clock that every RTP video codec shares. a=rtpmap:97 rtx/90000 with a=fmtp:97 apt=96 is the retransmission payload: it carries re-sent copies of payload 96 packets, as apt (associated payload type) says. The VP9 pair follows the same pattern, and a=fmtp:98 profile-id=0 selects VP9 profile 0.

  • The a=rtcp-fb lines 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) and transport-cc (the newer one). This is where Huddle’s recovery from a packet burst comes from.

  • a=ssrc-group:FID 73460372 2682249460 ties 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

pc.addTrack(track, stream)

Reuses a compatible idle transceiver (same kind, no sender track, never used to send) or creates one with direction sendrecv. Returns the RTCRtpSender. The stream argument only sets the msid.

pc.addTransceiver(trackOrKind, init)

Always creates a new transceiver. The init dictionary sets direction, streams and sendEncodings (simulcast layers). Use it when you need to receive without sending, or to control the direction and encodings from the start.

transceiver.direction

Your preference: sendrecv, sendonly, recvonly or inactive. Changing it raises negotiationneeded.

transceiver.currentDirection

What was negotiated. It is null before the first answer, and stopped after stop().

sender.replaceTrack(track)

Swaps the source (camera for a screen capture, or null to stop sending) without renegotiating, as long as the new track is compatible with the negotiated parameters.

sender.setParameters()

Changes encoding parameters such as maxBitrate, active and scaleResolutionDownBy without renegotiating.

transceiver.setCodecPreferences(codecs)

Reorders or restricts the codecs offered or answered for that section. The next offer or answer is generated from it.

transceiver.stop()

Permanently stops the transceiver. The next offer sets its m= port to 0, and after the answer it is dropped from pc.getTransceivers().

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.

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

signalingState (signalingstatechange)

stable, have-local-offer, have-remote-offer, have-local-pranswer, have-remote-pranswer, closed

Position in the offer/answer exchange. New transitions only come from setLocalDescription and setRemoteDescription.

iceGatheringState (icegatheringstatechange)

new, gathering, complete

Whether the ICE agent is still collecting candidates. It starts when a description is applied.

iceConnectionState (iceconnectionstatechange)

new, checking, connected, completed, disconnected, failed, closed

Health of the ICE transport only. disconnected is often temporary, failed ends the ICE agent’s attempts until restartIce().

connectionState (connectionstatechange)

new, connecting, connected, disconnected, failed, closed

Aggregate of ICE and DTLS across all transports. It reaches connected only after the DTLS handshake succeeds.

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 disconnected as a warning, not an error. Network changes (Wi-Fi to cellular, a laptop waking up) commonly pass through it and return to connected by themselves within seconds. Show a "reconnecting" badge, and start a timer.

  • Treat failed as final for the current candidates. Call pc.restartIce(), then send a new offer, which the negotiationneeded event will trigger. If it fails again, tear the connection down and rebuild it from scratch.

  • closed is only ever reached by your own close() call, and the object is then unusable.

  • Do not rely on completed: it is reported only by some browsers and connected is enough to start the call.

  • connectionState and iceConnectionState can disagree. If the DTLS fingerprint check fails, connectionState becomes failed while ICE says connected.

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.

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

InvalidStateError on setRemoteDescription

The description type does not fit the signaling state, for example an answer when the state is stable. Serialise all description handling through one queue, or use perfect negotiation.

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.

setRemoteDescription rejects an answer with a failure to apply the description of a media section

The answer proposed a codec, header extension or fingerprint that the offer did not contain. Always take the answer from setLocalDescription() on the callee, not from a different browser’s output.

Offers collide or a description is rejected while another negotiation is in flight

Calling setLocalDescription() while the state is not stable and a remote offer is pending. Wait for signalingState === 'stable', or let negotiationneeded and perfect negotiation do the work.

No audio or video but connected

The track event fired but the stream was not attached, or autoplay policy blocked playback of an element without muted or a user gesture. Call video.play() after a click, and check track.muted.

Remote video is black or frozen

The sender’s track was stopped or replaceTrack was given a track that is ended. Check track.readyState, and request a key frame by sending a PLI (the SFU does this on subscribe).

Works on LAN, fails across networks

No TURN configured, or TURN credentials expired. See ICE, STUN and TURN.

Candidates added too early

addIceCandidate() before setRemoteDescription() throws. Buffer the candidates received before the description and apply them afterwards.

Codec unexpectedly H264 or VP8

The answerer’s list did not contain your preferred codec, or setCodecPreferences was called after the offer had been created. Call it before setLocalDescription(), and read the result from getStats() (type: "codec").

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 createOffer and setLocalDescription, and the implicit no-argument setLocalDescription() 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 sdpSemantics can be removed.

  • a=extmap URIs such as the transport-wide congestion control extension and the goog-remb feedback are widely deployed but are documented only in expired Internet-Drafts, as the draft-holmer URI 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.