Debugging SDP m-line Mismatches Across Browsers
WebRTC enforces RFC 8843 strictly: the m= line sequence in an answer must mirror the offer position-for-position, and every mid must map to the same media section on both peers. When that invariant breaks, the browser rejects setRemoteDescription() outright β usually with no actionable detail β and your media silently never starts. This guide is part of the SDP Offer/Answer Lifecycle guide, and it isolates the exact decision of how to detect, reproduce, and fix m-line drift between Chrome, Firefox, and Safari before it reaches the native parser.
Context & Trade-offs
m-line order is an index-based contract. The browser binds transceiver N in the offer to transceiver N in the answer purely by position; the mid attribute names that binding but does not relax the ordering rule. Three things drift it in practice. First, hand-mutating the SDP string between createOffer() and setLocalDescription() reorders or drops sections. Second, engines disagree on how to represent unused sections: Firefox (since ~78) collapses an idle media section to a=inactive, while Chrome keeps an explicit recvonly/sendrecv direction, so an answer can carry a section the offerer did not expect in that slot. Third, asymmetric transceiver setup β one peer adds audio-only, the other expects audio plus video β produces a different section count entirely.
The cost of getting this wrong is total: a rejected answer means zero media, not degraded media. The cost of the fix is near-zero β controlling layout through RTCRtpTransceiver APIs instead of regex adds no latency and removes the entire failure class. The only case where pre-flight validation adds measurable overhead is logging raw SDP on every negotiation, which costs a few hundred microseconds per exchange and is worth it in production for the telemetry.
There is a deeper reason the parser is unforgiving here: with bundlePolicy: 'max-bundle', every media section shares one ICE transport and one DTLS session, multiplexed by mid. The a=group:BUNDLE 0 1 2 line at the top of the SDP enumerates the mids in order, and the demultiplexer routes inbound RTP to a transceiver by that mapping. Reorder the m= sections without updating the BUNDLE group, or rename a mid the group still references, and the demux table points at the wrong decoder β which is why a βharmlessβ string swap produces a hard rejection rather than a recoverable warning. The mismatch is not cosmetic; it breaks the routing invariant the whole transport depends on. This is the same BUNDLE contract that the SDP Offer/Answer Lifecycle state machine assumes stays intact across every renegotiation.
Rejected sections, port 0, and slot recycling
Stopping a transceiver does not delete its media section. The section survives with its port rewritten to zero β m=audio 0 UDP/TLS/RTP/SAVPF 111 β and its mid withdrawn from the a=group:BUNDLE line, because physically removing the line would renumber every section beneath it and invalidate the index contract for the remainder of the session. That is why the SDP of a long-lived room is usually longer than the live track count suggests: a meeting where twelve participants have joined and left carries twelve zeroed sections that both peers must still echo back position-for-position, adding roughly 150β300 bytes of signaling payload each.
Engines then differ on reuse. Chrome recycles a rejected slot when you add a new transceiver of the same kind, dropping the new sender into the first zeroed section with a fresh mid rather than appending; Firefox has historically been more conservative and appends, leaving the zeroed slot untouched. Both behaviours are legal under JSEP and correct in isolation β the mismatch appears when one side recycles and the other appends, so the same logical track lands at index 1 on one peer and index 4 on the other. The consequence for your validator is that filtering zero-port sections out before comparing reports a clean pass on a pair the native parser will still reject. Compare every section, live or rejected.
Minimal Runnable Implementation
The safest defence is a pre-flight validator that compares mid order between offer and answer before either reaches setRemoteDescription(), paired with transceiver-driven layout control so the drift never originates locally.
// Compare mid ordering between offer and answer SDP before applying either.
function validateMLineOrder(offerSDP, answerSDP) {
const mids = (sdp) =>
(sdp.match(/^a=mid:(\S+)/gm) ?? []).map(l => l.replace('a=mid:', ''));
const offer = mids(offerSDP);
const answer = mids(answerSDP);
if (offer.length !== answer.length) {
console.error(`m-line count mismatch: offer=${offer.length}, answer=${answer.length}`);
return false; // never call setRemoteDescription with this pair
}
// position-for-position match is what RFC 8843 requires
return offer.every((mid, i) => mid === answer[i]);
}
// Control layout natively so local SDP never drifts: align directions before negotiating.
function normaliseDirections(pc) {
// 'inactive' sections are the usual source of Firefox<->Chrome asymmetry
pc.getTransceivers().forEach(t => {
if (t.direction === 'inactive') t.direction = 'recvonly';
});
}
async function applyAnswerSafely(pc, answerSDP) {
if (!validateMLineOrder(pc.localDescription.sdp, answerSDP)) {
throw new Error('m-line drift detected; renegotiate instead of applying');
}
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP });
}
Use pc.localDescription.sdp as the offer reference β it is the normalised copy the browser committed, not the pre-commit string you generated. Comparing against the raw createOffer() output produces false positives because engines reorder attributes during the commit, as noted in the SDP Offer/Answer Lifecycle sequence.
Extending the check to BUNDLE membership
Matching mid order catches the two loudest failures but misses the quiet one: a group line that references a mid no section declares. That combination parses on Chrome and fails on WebKit, so it reaches production as an iOS-only bug. Adding the cross-check costs one extra regex and runs in well under a millisecond even on a twenty-section description.
// Verify every mid named in a=group:BUNDLE actually exists as a section.
function validateBundleGroup(sdp) {
const group = sdp.match(/^a=group:BUNDLE (.*)$/m);
if (!group) return true; // no bundling in use; nothing to cross-check
const declared = new Set(
(sdp.match(/^a=mid:(\S+)/gm) ?? []).map(l => l.slice('a=mid:'.length))
);
const referenced = group[1].trim().split(/\s+/);
const orphans = referenced.filter(mid => !declared.has(mid));
if (orphans.length) {
console.error(`BUNDLE references absent mids: ${orphans.join(', ')}`);
return false; // a regex renamed a mid without rewriting the group
}
// mids longer than 16 bytes overflow the one-byte MID header extension
return referenced.every(mid => mid.length <= 16);
}
Reproduction Steps & Debugging Log Patterns
- Generate an offer in Chrome with an audio and a video track, then intercept the SDP and manually swap the
m=audioandm=videoblocks. - Pass the mutated SDP to a Firefox peerβs
setRemoteDescription(). - Observe the immediate rejection:
RTCError: Failed to set remote answer sdp: The order of m-lines in answer doesn't match order in offer. - Repeat with asymmetric tracks β Safari offering audio-only to a Chrome peer that answers with audio plus video β and watch the offerer reject the extra section:
SDP parsing failed: m-line index 1 does not match expected mid. - Run
validateMLineOrder()on the raw payloads before applying; a clean negotiation logsBUNDLE alignment check: passed, a drifted one logsm-line count mismatch (local: 3, remote: 2).
Console signatures to watch for:
// Rejection patterns surfaced by the native parser
// "The order of m-lines in answer doesn't match order in offer."
// "InvalidStateError: Cannot set remote answer in state stable"
// "a=mid:0 mismatch: expected audio, found video"
// "Warning: BUNDLE group references non-existent mid"
In Chrome, chrome://webrtc-internals logs the full offer/answer text with timestamps β the dump format is broken down in Reading chrome://webrtc-internals Dumps β while in Firefox the same exchange appears in about:webrtc, the panel used throughout Diagnosing ICE Failures with Firefox about:webrtc. Diff the a=mid and m= lines between the two dumps β the first divergent index is your culprit.
A useful triage shortcut: capture both descriptions as soon as setRemoteDescription() rejects and compare three things in order β the section count, then the per-index mid, then the a=group:BUNDLE line. A count difference points at asymmetric transceiver setup; a same-count, different-order result points at a reorder bug or a hand-edit; a matching order with a BUNDLE line referencing an absent mid points at a regex that renamed a mid without updating the group. Each signature maps to exactly one class of fix, so you rarely need to read the full SDP line by line.
Failure mode: over-long mids that overflow the MID header extension
Teams that rewrite a=mid:0 into something self-documenting like mid:camera-front-participant-42 hit a failure that looks nothing like an SDP problem. The mid is not only an SDP attribute; it travels in every RTP packet as the MID header extension (urn:ietf:params:rtp-hdrext:sdes:mid), which is how a receiver demultiplexes a bundled stream before it has any SSRC mapping. The one-byte extension format caps that payload at 16 bytes, so an over-long mid is either refused at negotiation or truncated on the wire, leaving the receiver demuxing against a prefix that collides with a sibling section. The symptom is a connection that reaches connected, reports rising bytesReceived on the transport, and still shows every inbound track at zero frames, because packets arrive and are dropped at the demultiplexer. Any mid longer than 16 characters is a bug regardless of what the parser said; keep the browser-assigned ordinals and carry human-readable identity in your own signaling envelope, keyed by mid.
Failure mode: rid drift inside a simulcast section
A section carrying simulcast is two ordering contracts stacked on one another. a=rid:q recv, a=rid:h recv, a=rid:f recv declare the layer identifiers, and a=simulcast:recv q;h;f names them again in preference order; both must agree, and the answerer may narrow the list but must not reorder it. The dangerous property is that violating the inner contract usually does not throw. Drop a rid from the answer and setRemoteDescription() succeeds, negotiation completes, and one encoding simply never produces packets β you lose the 1/2-scale layer and the SFU falls back to whatever remains. Diagnose it by reading sender.getParameters().encodings after negotiation and confirming the length and rid values match what you configured, a check worth wiring into the same path that sets up Simulcast with Three Quality Layers in Chrome. A shorter array means the answer pruned a layer; a matching array with one encoding stuck at zero bitrate is an encoder problem, not an SDP one.
Common Implementation Mistakes
- Hand-editing
a=midvalues with regex without updating the matchinga=group:BUNDLEreferences, leaving the BUNDLE group pointing at a non-existent mid. - Comparing against the raw
createOffer()output instead ofpc.localDescription.sdp, producing spurious mismatch reports. - Assuming
m=line order is stable across Chrome, Firefox, and Safari engine updates β it is engine- and version-specific, and WebKitβs parser is the least forgiving of the three, which is why Debugging WebRTC on Safari and iOS WKWebView is worth having open when a mismatch only reproduces on iOS. - Letting an idle section stay
inactiveon one peer while the other emits an explicit direction, creating count or ordering asymmetry. - Calling
setRemoteDescription()beforesetLocalDescription()resolves, so the comparison runs against a half-committed local description β the same ordering hazard that produces the collisions handled in Recovering from Glare in Offer Collisions. - Reordering transceivers between renegotiations, which silently shifts every downstream
mid. - Stripping zero-port sections out of the SDP to βclean it upβ before forwarding it through a signaling server, which renumbers every remaining index and guarantees rejection on the next renegotiation.
- Caching an SDP body in the signaling layer and replaying it after a reconnect, so a description generated against an older transceiver layout is applied to a peer connection that has since added or recycled sections.
FAQ
Why do browsers reject SDP with identical m-line counts but different ordering? The offer/answer model binds transceivers to media sections by position, not by name. Reordering breaks the index-based association the native media engine uses to route each RTP stream to the correct decoder, so the parser refuses the answer even when the count matches.
How can I debug a mismatch without intercepting WebSocket traffic?
Inspect pc.getTransceivers() and pc.getSenders() for the local layout, then compare against pc.remoteDescription.sdp after negotiation. chrome://webrtc-internals and Firefox about:webrtc both log the raw exchange with timestamps, which is enough to find the divergent index.
Is it ever safe to reorder m-lines with regex in production?
No. Control media layout through RTCRtpTransceiver.setDirection() and setCodecPreferences(). The only defensible string edit is appending an unexposed codec parameter such as usedtx=1, covered in Munging SDP to Prefer Opus DTX, and even that must leave the m-line order and BUNDLE group untouched.
Does an ICE restart risk shifting m-line order?
No, and that is the point of using one. An ICE restart regenerates the a=ice-ufrag and a=ice-pwd values and re-gathers candidates while leaving the media section list byte-for-byte identical, which is why Triggering an ICE Restart Without Dropping Media is the safe recovery path after a network change. Ordering only moves when a transceiver is added, stopped, or recycled. If you see mids shift across what you believed was a pure restart, something in your code called addTransceiver() or addTrack() on the same tick β usually a reconnect handler re-running the initial setup.
Should I switch to bundlePolicy: 'max-compat' to avoid these failures?
It removes the BUNDLE cross-check but not the ordering rule, so you trade one failure class for a worse transport. max-compat negotiates a separate ICE and DTLS transport per media section, which multiplies candidate gathering and DTLS handshakes by the section count and typically adds 200β800 ms to time-to-first-frame on a three-section call β more when every path has to fall back to a relay that already costs 20β40 ms one-way. Keep max-bundle and fix the ordering bug.
What is the smallest reliable regression test for this?
Negotiate a call, stop one transceiver, add a new one of the same kind, renegotiate, and assert the mid arrays from localDescription and remoteDescription match index-for-index. That sequence exercises rejected sections, slot recycling, and BUNDLE rewriting at once, in under a second of test time.
Related: return to the SDP Offer/Answer Lifecycle guide, and compare with SDP Renegotiation Without Dropping Streams and Munging SDP to Prefer Opus DTX.