Transport-CC vs REMB Feedback

Both mechanisms answer the same question β€” how much can this sender push right now β€” but they answer it from opposite ends of the wire. REMB computes the estimate on the receiver and mails back a single number; transport-wide congestion control computes it on the sender and mails back raw arrival timings. This guide is part of the Bandwidth Estimation & Congestion Control guide, and it settles one practical question: which SDP lines put your call on which mechanism, and how to prove after the fact which one actually drove the bitrate.

Context & Trade-offs

REMB (Receiver Estimated Maximum Bitrate) is an RTCP payload-specific feedback message β€” PT 206, FMT 15, with the four ASCII bytes REMB as its unique identifier β€” carrying a bitrate encoded as a 6-bit exponent plus an 18-bit mantissa, followed by the list of SSRCs the estimate applies to. The receiver runs the whole arrival-time filter locally, collapses it to that one scalar, and the sender’s only job is to obey. Because the number is a ceiling and nothing more, the sender learns what to do and never why.

Transport-cc is an RTCP transport-layer feedback message β€” PT 205, FMT 15 β€” and it carries no estimate at all. The sender stamps a 16-bit transport-wide sequence number into a one-byte RTP header extension on every outgoing packet; the receiver echoes back which of those sequence numbers arrived and, for each, the inter-arrival delta in 250 Β΅s ticks. The estimator then runs on the sender, where the true send timestamps already live.

That last point is the substance of the speed difference, and it is easy to under-rate. A sender-side estimator compares its own send clock against reported arrivals, so there is no clock-offset estimation and no dependence on abs-send-time, whose 6.18 fixed-point field wraps every 64 seconds. More importantly, only the sender knows which packets were probe packets. WebRTC’s pacer periodically emits a short probe burst at 1.5–2Γ— the current estimate to test for headroom; a receiver looking at the arrival stream cannot tell that burst from a burst of real frames, so REMB either ignores the probe or misreads it as over-use. Sender-side estimation attributes those packets correctly and can ramp on the evidence.

The historical order explains the design. REMB predates the transport-wide sequence number extension, and at the time the receiver was the only party that could see arrival times at all β€” the sender had no channel to get them back. Once a general header-extension mechanism existed, moving the filter to the sender became strictly better, because the sender is also the party that owns the pacer, the encoder, the probe schedule and the simulcast layer allocation. Every one of those decisions needs the raw arrival trace, not a scalar someone else derived from it.

Where the estimate is computed in each mechanismThe upper panel shows REMB: the receiver runs the arrival filter and returns one aggregate bitrate scalar to a passive sender. The lower panel shows transport-wide congestion control: the receiver only records arrival deltas, which cross back as raw data, and the sender runs the filter next to its own pacer and send clock.The filter runs on one side or the other β€” never bothREMB β€” receiver computesSenderpacer + encoderno filter hereobeys the numberReceiverarrival filterover-use detectorowns the estimateRTP + abs-send-time (wraps every 64 s)PT 206 / FMT 15 β€” one scalar: 1 740 000 bpsemitted on the RTCP tick, ~1 s aparttransport-cc β€” sender computesSendertrendline filterknows its probesowns the estimateReceiverrecords arrivalsno filter herereports, never decidesRTP + 16-bit transport-wide sequence numberPT 205 / FMT 15 β€” arrival deltas, 250 Β΅s ticksemitted every 50–100 msOne sequence space spans every SSRC on the bundled transport β€” audio, video, simulcast layers, RTX and FEC share one estimate.
REMB ships a conclusion; transport-cc ships evidence. Only the second lets the sender interpret its own probe clusters.

The transport-wide sequence space matters for a second reason. It is stamped per transport, not per SSRC, so with BUNDLE a single feedback stream covers audio, video, every simulcast layer, RTX and FEC at once β€” one estimate to divide across the whole session. REMB is scoped to the SSRC list it names, which in a multi-stream session means several partial estimates that no component reconciles.

Dimension REMB transport-cc
Estimate computed on receiver sender
RTCP type PT 206 / FMT 15 (PSFB) PT 205 / FMT 15 (RTPFB)
Payload one bitrate, exp + mantissa per-packet arrival deltas
Cadence ~1 s tick; immediate on a >3% drop 50–100 ms
Scope the named SSRC list whole bundled transport
Probe interpretation impossible native
Feedback overhead negligible ~1–2% of the media rate

The one genuine cost of transport-cc is uplink overhead on the receiving side: at a 50 ms cadence a report covering a 2 Mbps video stream runs a few hundred bytes, roughly 1–2% of the media rate. That is the price of resolution, and it is the reason the cadence is adaptive rather than fixed.

Minimal Runnable Implementation

Neither mechanism is switched on by an API call β€” both are pure SDP negotiation. Four lines in the m=video section decide everything, and they come in two independent pairs.

; --- transport-cc pair: BOTH lines are required, in offer AND answer ---
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=rtcp-fb:96 transport-cc          ; receiver agrees to echo the sequence numbers back
; --- REMB pair: legacy, inert whenever the pair above survives ---
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=rtcp-fb:96 goog-remb             ; receiver may emit PT 206 reports
; --- unrelated to bandwidth estimation, often confused with it ---
a=rtcp-fb:96 nack                  ; retransmission
a=rtcp-fb:96 ccm fir               ; full intra request

Both rtcp-fb attributes are keyed to a payload type, so they repeat once per codec in the section β€” payload type 96 and its RTX partner 97, then again for every other negotiated codec. Some stacks emit the wildcard form a=rtcp-fb:* transport-cc instead, which applies to every payload type in the section; treat the two spellings as equivalent when you grep. The extmap attribute, by contrast, appears once per media section and carries a numeric id that has to be identical in offer and answer for the extension to survive the round trip.

The asymmetry that bites people: a=rtcp-fb:96 transport-cc on its own does nothing. The rtcp-fb line only says the receiver is willing to send the reports; without the matching a=extmap line negotiated in both descriptions the sender never stamps a sequence number, so there is nothing to report on and the estimator quietly falls back to loss-only. An answerer that keeps the rtcp-fb line and drops the extmap line produces exactly this β€” an SDP that looks correct on inspection and an estimate pinned near its 300 kbps start value.

Which SDP lines enable which mechanismAn annotated m-equals-video section. Two lines in cyan enable transport-wide congestion control, two in purple enable REMB, and two feedback lines at the bottom concern retransmission rather than bandwidth estimation. A footer notes that both extmap and rtcp-fb must survive into the answer.Two independent pairs inside one m=video sectionm=video 9 UDP/TLS/RTP/SAVPF 96 97a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01stamps the seq numbera=rtcp-fb:96 transport-ccechoes it backa=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-timereceiver's timing sourcea=rtcp-fb:96 goog-rembpermits PT 206 reportsa=rtcp-fb:96 nacka=rtcp-fb:96 ccm firrecovery, notestimation β€” leaveboth enabledEach pair counts only if both of its lines survive into the answer β€” the intersection is what runs.
The rtcp-fb line grants permission; the extmap line supplies the data. A pair with one half missing is a silent no-op.

Reproduction Steps & Debugging Log Patterns

  1. Establish the call, then intersect the two negotiated descriptions rather than trusting the offer. Only lines present in currentLocalDescription and currentRemoteDescription are active.
  2. Cross-check against what the stack actually applied: in Chrome, sender.getParameters().headerExtensions lists the negotiated extensions with their final IDs.
  3. Force the answering side to drop the extmap line and rerun β€” the estimate should collapse to a loss-only ceiling and stop tracking capacity.
  4. Open chrome://webrtc-internals, take an RTC event log dump, and count goog-remb versus transport feedback messages. SRTCP means a packet capture will not dissect these; the event log records them pre-encryption, which is why it is the right tool here.
// Which mechanism did this call actually negotiate? Intersect both descriptions.
const TWCC_EXT = 'transport-wide-cc-extensions'; // substring of the draft-holmer URI
const active = (probe) =>
  probe.test(pc.currentLocalDescription?.sdp ?? '') &&   // we offered/answered it
  probe.test(pc.currentRemoteDescription?.sdp ?? '');    // and so did the peer

const twccExt = active(new RegExp(TWCC_EXT));        // the header extension itself
const twccFb  = active(/a=rtcp-fb:\S+ transport-cc/); // permission to report on it
const remb    = active(/a=rtcp-fb:\S+ goog-remb/);    // legacy path still advertised

// transport-cc needs BOTH halves; the fb line alone is a silent no-op.
const mechanism = (twccExt && twccFb) ? 'transport-cc' : (remb ? 'REMB' : 'loss-only');
console.log(`[bwe] ext=${twccExt} fb=${twccFb} remb=${remb} -> ${mechanism}`);

// Confirm against what the stack applied, not just what the SDP claims.
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const ids = (sender.getParameters().headerExtensions ?? [])   // Chrome exposes this
  .filter(h => h.uri.includes(TWCC_EXT))
  .map(h => h.id);                                            // expect exactly one id, 1–14
console.log(`[bwe] transport-cc extension ids applied: ${ids.join(',') || 'none'}`);

A healthy transport-cc session logs ext=true fb=true remb=true -> transport-cc β€” REMB advertised but inert β€” followed by an extension id in the 1–14 range. The failure signature is ext=false fb=true remb=true -> REMB with an empty id list: the feedback line negotiated, the extension did not, and every downstream symptom (a flat estimate, no response to a capacity change) follows from that one missing line. If both report true but the id list is empty, the stack rejected the id rather than the URI β€” see the extmap-allow-mixed note below.

Two browser caveats affect how much of this you can observe. Firefox negotiates transport-cc and consumes it normally, but getParameters().headerExtensions is not a reliable mirror of the applied extensions there, so fall back to the SDP intersection and about:webrtc. Safari negotiates transport-cc as well, though it advertises a narrower extension set, and its inspector exposes no equivalent of the RTC event log β€” on Safari the SDP intersection is the whole of your evidence, which is a good reason to log the mechanism string above at connection time and ship it with your session telemetry rather than reconstructing it during an incident.

Reaction latency after a capacity cliffA time axis from zero to twelve hundred milliseconds. At time zero uplink capacity falls from three megabits to one megabit. The transport-cc lane shows feedback arriving at sixty milliseconds, over-use confirmed at one hundred ten, and the sending rate cut at one hundred forty. The REMB lane shows the receiver detecting over-use at one hundred twenty but the report waiting for the RTCP tick at one thousand milliseconds, with the cut applied at one thousand and thirty.Same cliff, same detector β€” the difference is when the sender hears about itt=0 uplink 3 Mbps β†’ 1 Mbps020040060080010001200milliseconds after the capacity droptransport-cc60 msfeedback110 msover-use140 ms β€” pacer and encoder cutREMB120 msreceiver knowswaiting for the RTCP tick β€” sender still pushing 3 Mbps1000 ms1030 ms cutβ‰ˆ890 ms of over-sending into a full queue β€” the frames that arrive late or not at all
Both detectors fire within ~120 ms; only transport-cc gets the news to the sender before the queue has swallowed a second of video.

Common Implementation Mistakes

FAQ

Should I strip goog-remb from my SDP once transport-cc works?

No. It costs two lines and buys interoperability with older gateways and native clients that never implemented transport-cc. When both are negotiated the newer mechanism wins outright, so the REMB lines are dead weight rather than a hazard β€” and removing them means a peer that only speaks REMB gets no estimate at all.

Can a call use transport-cc for video and REMB for audio?

Not meaningfully. The transport-wide sequence number is stamped per transport, so under BUNDLE a single feedback stream already covers both media sections and produces one estimate for the whole transport. Per-m-section rtcp-fb lines can differ, but the resulting estimate is not partitioned by media type β€” allocation across audio and video happens inside the sender, after the estimate.

Why does my estimate stay near 300 kbps even though transport-cc is negotiated?

That is the cold-start floor, and it means no usable feedback is arriving. Check the extmap intersection first, then whether a TURN relay or middlebox is stripping unknown RTP header extensions β€” the diagnosis path is the same one used when Tuning WebRTC Bandwidth Estimator for Unstable Networks, where a flat estimate and a conservative estimate look identical until you inspect the feedback path.

Related: return to Bandwidth Estimation & Congestion Control, confirm the effect on the sender with Interpreting getStats() for Congestion Signals, trace the negotiated lines with Reading chrome://webrtc-internals Dumps, and see how a mid-call change propagates in SDP Renegotiation Without Dropping Streams.