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

  1. Generate an offer in Chrome with an audio and a video track, then intercept the SDP and manually swap the m=audio and m=video blocks.
  2. Pass the mutated SDP to a Firefox peer’s setRemoteDescription().
  3. Observe the immediate rejection: RTCError: Failed to set remote answer sdp: The order of m-lines in answer doesn't match order in offer.
  4. 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.
  5. Run validateMLineOrder() on the raw payloads before applying; a clean negotiation logs BUNDLE alignment check: passed, a drifted one logs m-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

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.