Skip to content
WebRTC.wiki

Stop munging SDP: the WebRTC APIs that replace it

Editing SDP before setLocalDescription breaks the WebRTC spec and still works in every browser. The API that replaces each munge, and the one case with none.

4 min readupdated

The string that comes out of createOffer looks like a configuration file. Line-oriented syntax, obvious keys, values you can see are wrong. So people edit it, and WebRTC developers call that SDP munging. Reorder the payload types to force VP9. Drop in a b=AS: line to cap the bitrate. Splice in the simulcast attributes the API would not hand over.

It is not a configuration file. It is a serialization of state the browser is already holding, and the browser will keep using its own copy no matter what you do to the string. That difference is the whole article.

Is SDP munging allowed? What the spec says

The setLocalDescription algorithm in WebRTC 1.0 is not ambiguous about this:

If type is “offer”, and sdp is not the empty string and not equal to connection.[[LastCreatedOffer]], then return a promise rejected with a newly created InvalidModificationError and abort these steps.

Byte for byte, against the exact string createOffer produced. Change one character and the promise should reject.

No browser does this. Chrome, Firefox and Safari all accept a modified description without complaint, and they have done it for long enough that when someone asked whether munging was actually illegal, the discussion turned to whether the spec should be relaxed rather than whether the browsers should be fixed.

That leaves you in the least comfortable position a web developer can occupy. The behaviour you depend on is forbidden by the specification, implemented by every engine, exercised by no conformance test, and promised by nobody. Chrome has been building the case quietly since version 82, when chrome://webrtc-internals started flagging any description that changed between createOffer and setLocalDescription using exactly that string comparison.

Why munged SDP breaks two renegotiations later

Look at what setParameters rejects and the design becomes obvious. You cannot change the number of encodings. You cannot reorder them. You cannot touch a read-only parameter such as RID or cname. The spec says why in a note: the dictionary is “designed to not enable” changes outside the envelope negotiated by offer/answer.

Munging is how you get outside that envelope, and getting outside it is the entire problem. The moment the string and the internal state disagree, the browser goes on using the internal state, and your SDP becomes a description of a session that does not exist. You do not find out immediately. You find out two renegotiations later, when a track that was working silently stops, and the SDP on the wire looks perfectly reasonable.

The API that replaces each munge

Codec order, and removing a codec. RTCRtpTransceiver.setCodecPreferences(), fed from RTCRtpReceiver.getCapabilities(kind).codecs. It will not let you invent a codec: passing anything not in the capability list throws InvalidModificationError, as does a list containing only RTX, RED, FEC or comfort noise, since that would leave nothing to offer.

const { codecs } = RTCRtpReceiver.getCapabilities('video');
const av1 = codecs.filter((c) => c.mimeType === 'video/AV1');
transceiver.setCodecPreferences([...av1, ...codecs.filter((c) => !av1.includes(c))]);

Bitrate. RTCRtpSender.setParameters() with encodings[0].maxBitrate, rather than a b=AS: or b=TIAS: line. Read, mutate, write back, always from a fresh getParameters().

const params = sender.getParameters();
params.encodings[0].maxBitrate = 500_000;
await sender.setParameters(params);

Simulcast. sendEncodings on addTransceiver(). This is the one that catches people, because it is the only chance you get. setParameters rejects a change to the number of encodings, so if you did not declare your layers when you created the transceiver, you cannot add them later. If you leave scaleResolutionDownBy off, the browser fills in powers of two from the bottom, so three layers become 4, 2 and 1.

pc.addTransceiver(track, {
  direction: 'sendonly',
  sendEncodings: [
    { rid: 'lo', scaleResolutionDownBy: 4, maxBitrate: 150_000 },
    { rid: 'mid', scaleResolutionDownBy: 2, maxBitrate: 500_000 },
    { rid: 'hi', maxBitrate: 1_500_000 },
  ],
});

Header extensions. getHeaderExtensionsToNegotiate() and setHeaderExtensionsToNegotiate() on the transceiver, with getNegotiatedHeaderExtensions() to see what survived. These live in WebRTC Extensions rather than the core spec, which is worth knowing before you rely on them everywhere.

Hold. transceiver.direction = 'inactive', not a search and replace on a=sendrecv.

DTMF. RTCDTMFSender. It has had its own interface since the beginning and never needed the SDP touched at all.

Stream membership. sender.setStreams(), instead of rewriting a=msid lines by hand.

A different codec per layer. RTCRtpEncodingParameters.codec covers this, though it is still a candidate addition to the spec rather than settled text, so check what your target browsers actually ship.

Opus stereo still needs munging

Opus format parameters. If you want stereo, you need stereo=1 and sprop-stereo=1 on the a=fmtp line, and there is no API for it. Same for maxaveragebitrate, usedtx and cbr. The issue asking for one has been open for years.

So people editing that line are not doing it out of habit, they are doing it because the platform gave them nothing else. If that is you, keep the edit to one regular expression on one line, put it behind a single function with the reason written above it, and re-run your audio tests on every browser release. That is the difference between a known workaround and a landmine.

Find the munging left in your code

Chrome will tell you. Open chrome://webrtc-internals during a call and look for the flag on the local description. If you would rather not trust a devtools panel, keep the string createOffer gave you and compare it to what you pass in:

const offer = await pc.createOffer();
const original = offer.sdp;
offer.sdp = mungeSomehow(offer.sdp);
if (offer.sdp !== original) console.warn('munged before setLocalDescription');
await pc.setLocalDescription(offer);

Ugly, and it will find things you forgot about in code written by someone who left two years ago.

The reason to delete these is not that the spec disapproves. It is that every remaining munge is a standing bet that the browser’s internal state will keep agreeing with a string you rewrote behind its back, and nobody has told you when that bet expires.