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.
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.
Reproduction Steps & Debugging Log Patterns
- Establish the call, then intersect the two negotiated descriptions rather than trusting the offer. Only lines present in
currentLocalDescriptionandcurrentRemoteDescriptionare active. - Cross-check against what the stack actually applied: in Chrome,
sender.getParameters().headerExtensionslists the negotiated extensions with their final IDs. - Force the answering side to drop the
extmapline and rerun β the estimate should collapse to a loss-only ceiling and stop tracking capacity. - Open
chrome://webrtc-internals, take an RTC event log dump, and countgoog-rembversus 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.
Common Implementation Mistakes
- Adding
a=rtcp-fb:96 transport-ccwithout theextmapline. The most common munging error. Permission without data means no reports, and the estimator degrades to loss-only without logging anything. - Reading only the local description. An offer that advertises both mechanisms proves nothing; a legacy gateway or an older SFU may answer with
goog-rembalone. Always intersect local and remote. - Renumbering
extmapids past 14 withouta=extmap-allow-mixed. One-byte extension headers only encode ids 1β14. Assign 15 or higher without negotiating mixed one- and two-byte headers and the extension is dropped on the wire while the SDP still looks perfect. - Expecting REMB to act as a fallback mid-call. It does not fail over. If transport-cc is negotiated, modern Chrome and Firefox ignore incoming REMB entirely for the life of the session; only a renegotiation changes the mechanism.
- Forwarding transport-wide sequence numbers unchanged through an SFU. The sequence space belongs to one hop. A server that relays packets to a subscriber must re-stamp them against its own counter, as covered in Rewriting RTP Header Extensions When Forwarding; pass them through untouched and every subscriberβs estimator sees a corrupted timeline.
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.