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.
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.
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; in Firefox, about:webrtc shows the same exchange. 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.
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. - 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. - Reordering transceivers between renegotiations, which silently shifts every downstream
mid.
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.
Related: return to the SDP Offer/Answer Lifecycle guide, and compare with SDP Renegotiation Without Dropping Streams and Munging SDP to Prefer Opus DTX.