Simulcast & SVC Implementation in WebRTC
Multi-stream encoding is how a single publisher satisfies a room full of subscribers on wildly different downlinks — one sends 1.5 Mbps of 720p, another a 150 kbps thumbnail, from the same camera and the same RTCPeerConnection. This guide is part of the Media Handling, Codecs & Bandwidth Estimation guide, and its job is to make simulcast and SVC work end-to-end: defining RID-based encodings, mapping scaleResolutionDownBy across three spatial tiers, switching to a single SVC stream with scalabilityMode, and forwarding the right layer per subscriber from the server. Get the encoder configuration wrong and you ship three identical-resolution streams that melt the CPU; get the keyframe handling wrong and subscribers see green-block corruption every time the server upgrades them.
Simulcast and SVC solve the same problem — one encode, many downstream qualities — with opposite trade-offs. Simulcast runs N parallel encoders and emits N independent RTP streams; the Selective Forwarding Unit just forwards whichever stream matches a subscriber, never touching codec internals. SVC runs one encoder that emits one stream layered so the server can drop frames to downscale. The sections below build both, call out where Chrome, Firefox, and Safari diverge, and link to the server-side forwarding logic that consumes them.
Step 1 — Declare RID-based simulcast encodings
Simulcast is configured entirely on the sender, before the first offer. You attach the track, read the parameters, replace the encodings array with one entry per quality tier, and write it back. Each entry carries a rid (the RTP stream identifier the SFU keys on), an active flag, a maxBitrate ceiling, and a scaleResolutionDownBy factor. The browser then emits a=simulcast and a=rid lines into the SDP automatically — you never hand-edit them.
The timing is strict: setParameters() must complete before createOffer(). You cannot add, remove, or rename a rid after negotiation; only the per-encoding active, maxBitrate, and scaleResolutionDownBy are mutable at runtime. Pin a codec that supports independent layers first — in Chrome that means VP8, VP9, or AV1, because Chromium maps H.264 onto SVC and caps it at two simulcast layers. The Chrome-specific recipe, including the exact chrome://webrtc-internals SSRC check, is in Simulcast with Three Quality Layers in Chrome.
The rid value is not cosmetic — it is the join key between this sender and the SFU. Whatever string you choose (high/mid/low, or f/h/q as some stacks use) appears verbatim in the a=rid SDP lines and in every outbound-rtp stats report, and the server’s forwarding table is built from exactly those identifiers. Keep them stable across your client and server code; a mismatch means the SFU has streams it cannot route. Note also the encoding order: list the highest-resolution layer first so the encoder treats it as the base and derives the scaled-down layers from it. Reversing the order makes some encoder builds scale up, producing blurry top layers.
const transceiver = pc.addTransceiver(videoTrack, {
direction: 'sendonly',
// Order matters: 'high' first so the encoder treats it as the base resolution
sendEncodings: [
{ rid: 'high', active: true, maxBitrate: 1_500_000, scaleResolutionDownBy: 1.0, maxFramerate: 30 },
{ rid: 'mid', active: true, maxBitrate: 500_000, scaleResolutionDownBy: 2.0, maxFramerate: 30 },
{ rid: 'low', active: true, maxBitrate: 150_000, scaleResolutionDownBy: 4.0, maxFramerate: 15 }
]
});
// Prefer VP8/VP9/AV1 before the offer — H.264 silently collapses to 2-layer SVC in Chrome
const caps = RTCRtpSender.getCapabilities('video');
const vp8 = caps.codecs.filter(c => /vp8/i.test(c.mimeType));
transceiver.setCodecPreferences([...vp8, ...caps.codecs]);
const offer = await pc.createOffer(); // a=simulcast + a=rid:high/mid/low now emitted automatically
await pc.setLocalDescription(offer);
The offer that comes back carries a video m-section you should learn to read at a glance, because every downstream problem shows up here first — a missing a=rid line, a pruned layer in the answer, or an H.264 payload where you expected VP8. The annotated layout below maps each line back to the sender configuration that produced it.
sendEncodings; if a line is missing here, the layer does not exist on the wire.Why RID replaced SSRC-grouped simulcast
Older stacks signalled simulcast with a=ssrc-group:SIM 111 222 333 — a list of synchronisation sources declared in the SDP, with the receiver expected to infer that the first was the top layer. That scheme died with Plan B (Chrome removed the last of it in M93) for two reasons worth understanding, because both explain the shape of the modern API. First, SSRCs are 32-bit random values chosen by the sender at negotiation time; if the sender ever regenerates one — after an ICE restart, or a stream replacement — the server’s forwarding table points at a stream that no longer exists, and there is no in-band way to notice. Second, an SSRC carries no semantics: nothing in the packet says “this is the 180p layer”, so the SFU had to trust SDP ordering that browsers did not implement identically.
RID fixes both by moving the identity into the RTP packets themselves. The a=extmap:… urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id line negotiates a one-byte header extension, and every packet the sender emits carries its layer name inline. The server reads the RID from the header, not from a table, so an SSRC change is a non-event. Retransmissions get their own sdes:repaired-rtp-stream-id extension so RTX packets can be attributed to the layer they repair — without it an SFU forwarding NACK repairs to a subscriber on the low layer can leak high-layer retransmissions and blow its bitrate budget. If the extmap line is missing from the negotiated answer, the layers exist on the wire but are anonymous, and every SFU you point at them will forward exactly one of the three. Check for it in the answer before you check anything else.
Step 2 — Map scaleResolutionDownBy across 1/2/4
scaleResolutionDownBy is the single most important field for keeping CPU sane. It divides the capture resolution before encoding, so a 1280×720 capture with factors 1.0 / 2.0 / 4.0 produces 720p, 360p, and 180p streams. Omit it and you get three full-resolution encodes — roughly 3× the encoder load with no quality benefit, the fastest way to exhaust a laptop CPU mid-call. Keep the factors as clean powers of two; fractional ratios like 1.5 force the scaler onto non-aligned dimensions that some hardware encoders reject. Whether those parallel encodes land on silicon or fall back to libvpx changes the ceiling by an order of magnitude, so confirm which path you are on using the probes in Detecting Hardware vs Software Encoding before blaming the layer count.
Pair each resolution with a maxBitrate that leaves clear headroom between tiers — a useful rule is that each layer’s ceiling should be at least 2× the layer below it, or the bandwidth estimator treats two layers as one and drops the higher of the pair. Don’t hardcode these ceilings as the actual send rate; they are caps, and WebRTC’s Google Congestion Control allocates the real bitrate underneath them. The interaction between simulcast ceilings and the estimator is covered in Bandwidth Estimation & Congestion Control.
| RID | scaleResolutionDownBy | Resolution (from 720p) | maxBitrate | maxFramerate |
|---|---|---|---|---|
| high | 1.0 | 1280×720 | 1.5 Mbps | 30 |
| mid | 2.0 | 640×360 | 500 kbps | 30 |
| low | 4.0 | 320×180 | 150 kbps | 15 |
At runtime you adapt by flipping active per layer rather than renegotiating. Dropping the high layer under sustained loss frees its entire bitrate budget for the survivors without an SDP round trip:
// Disable the top layer without renegotiation — no createOffer needed
function setLayerActive(sender, rid, active) {
const params = sender.getParameters();
const enc = params.encodings.find(e => e.rid === rid);
if (enc) enc.active = active; // mutable at runtime; rid itself is frozen
return sender.setParameters(params);
}
How the rate allocator decides which layers survive
Setting three encodings does not guarantee three streams, and the reason is the sender-side rate allocator sitting between the bandwidth estimate and the encoders. libwebrtc gives every simulcast layer a minimum bitrate as well as your ceiling, and it fills those minimums bottom-up: the low layer is funded first, then mid, then high. A layer is only switched on once the estimate covers the minimums of that layer and everything below it, plus a margin. With the 150/500/1500 kbps table above, the top layer typically needs roughly 1.8–2.0 Mbps of estimated uplink before it starts, because the allocator is holding back the 650 kbps the two lower tiers already claim. On a 1 Mbps uplink you will therefore see two outbound-rtp rows, not three, and nothing anywhere reports an error — the third layer is simply unfunded.
This is why the “2× separation” rule matters more than the absolute numbers: closely packed ceilings compress the funding thresholds so that two layers cross their activation points within the estimator’s noise band, and the allocator flips the top one on and off every probing cycle.
The other budget is CPU, and simulcast’s cost is not 3× the top layer. Encoding 720p + 360p + 180p is about 1.31× the pixel rate of 720p alone, but each extra encoder instance carries fixed per-frame overhead — motion search setup, rate-control state, entropy coder init — so measured software encoding lands nearer 1.6–1.9× a single 720p encode. Hardware changes the picture again: many platform encoders expose a limited number of concurrent sessions, and when Chrome cannot get three it falls back to software for the layers it cannot place, which is how a machine that comfortably runs one hardware 720p stream suddenly saturates a core. Watch qualityLimitationReason in getStats(): a value of cpu means the encoder is shedding resolution or frame rate on its own and your ceilings are irrelevant, while bandwidth points back at the allocator. Screen shares distort both budgets — a static 1440p desktop capture costs almost nothing until someone scrolls, so pair the layer plan with the contentHint guidance in Keeping Shared Text Readable with contentHint rather than reusing the camera tiers verbatim.
Step 3 — Switch to SVC with scalabilityMode L3T3
SVC replaces N parallel encoders with one encoder that structures its single output into decodable sub-layers. Instead of a rid array you configure one encoding with a scalabilityMode string. L3T3 means 3 spatial layers and 3 temporal layers — nine forwardable operating points from one RTP stream — and L3T3_KEY adds keyframe-synchronised spatial layers so the server can upgrade a subscriber’s resolution at a shared keyframe boundary. VP9 and AV1 expose full spatial SVC; the AV1-specific layer planning and CPU budget live in Configuring AV1 SVC Layers in WebRTC.
SVC’s win is a single encode pass and one SSRC, so CPU and bandwidth overhead are lower than simulcast’s parallel encoders. The cost moves to the server: the SFU must parse the dependency descriptor to know which packets belong to which layer. The decision of which mechanism to deploy at scale — and where each one wins past 50 participants — is worked through in Choosing Simulcast vs SVC for Large Conferences.
The temporal and spatial axes serve different adaptation goals. Temporal layers (the T digit) let the SFU halve frame rate per subscriber — drop the top temporal layer and a 30 fps stream becomes 15 fps at a fraction of the bitrate, with no resolution change. Spatial layers (the S/L digit) let it halve resolution. A conference that mostly needs to absorb brief congestion spikes benefits more from temporal layers, because frame-rate drops are visually gentler than resolution drops and recover instantly. Rooms with a wide spread of screen sizes — phones next to large displays — need the spatial range. L3T3 gives both, which is why it is the common default once a codec supports full spatial SVC. The same sharpness-versus-smoothness question shows up on the sender side as degradationPreference: Resolution vs Framerate, and the two settings should agree — a screen-share tuned to hold resolution should not sit behind an SFU that strips spatial layers first.
const sender = pc.addTrack(videoTrack, stream);
const params = sender.getParameters();
params.encodings = [{
active: true,
maxBitrate: 2_000_000,
scalabilityMode: 'L3T3_KEY' // 3 spatial + 3 temporal, keyframe-synced spatial upgrades
}];
await sender.setParameters(params);
// No rid array: a single SSRC carries all layers, distinguished by the dependency descriptor
L3T3 versus L3T3_KEY, and why the suffix changes your bill
The _KEY suffix is not a minor variant; it changes what the SFU is obliged to forward. In plain L3T3, every frame of a spatial layer predicts from the co-located frame of the layer beneath it, so a subscriber watching 720p needs S0, S1 and S2 delivered continuously — the server forwards the full 1.5 Mbps stack to get 720p, and the lower tiers are pure overhead for that viewer. In L3T3_KEY (K-SVC) inter-layer prediction happens only at keyframes; between keyframes each spatial layer predicts from its own history. The server can therefore forward S2 alone once the subscriber has decoded one keyframe, dropping roughly the 350–400 kbps that S0 and S1 would have consumed on that link. The trade is at the seam: because upgrades must land on a keyframe, a K-SVC promotion has the same PLI-and-wait latency as a simulcast layer switch, whereas full L3T3 can add a spatial layer on any frame.
That gives a clean rule. If most subscribers sit at the top quality and the room is bandwidth-constrained downstream, L3T3_KEY is the better default. If subscribers churn between qualities constantly — a large gallery view where the active speaker changes every few seconds — full L3T3 buys instant upgrades at the cost of always shipping the lower tiers.
The server distinguishes layers differently per codec, which matters when you write or configure the SFU. AV1 carries the generic dependency descriptor header extension, negotiated as a=extmap:… https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension, and it describes the whole dependency graph without the server parsing a single bit of codec payload. VP9 predates it and puts its scalability structure in the payload descriptor, so an SFU has to reach into the first bytes of the RTP payload to read spatial and temporal indices. Stacks that rewrite header extensions on the forwarding path have to handle both, and getting that wrong is a common source of one-way freezes — the mechanics are in Rewriting RTP Header Extensions When Forwarding.
Step 4 — SFU layer selection and keyframes
Whichever mechanism you ship, the server makes the actual quality decision per subscriber. For simulcast the SFU matches each subscriber’s estimated downlink against the available rid streams and forwards the highest one that fits, dropping the others’ RTP packets without decoding. For SVC it reads the dependency descriptor and forwards only the spatial/temporal layers the subscriber can afford. This receiver-driven selection — not sender-driven — is the correct model for any SFU topology; sender-driven switching belongs only to P2P mesh. The full algorithm, including hysteresis to stop layer flapping, is in Bandwidth-Aware Layer Selection in an SFU and the forwarding mechanics in Simulcast-Aware Forwarding.
The hard part is keyframes. A spatial or rid upgrade is only decodable from a keyframe — forward the first packets of a higher layer mid-GOP and the subscriber renders corruption until the next keyframe arrives on its own. So when the SFU promotes a subscriber, it sends a PLI (Picture Loss Indication) upstream to the publisher and holds the upgrade until the resulting keyframe boundary. The ordering below is the whole trick, and the same handshake is what keeps a promotion from showing as a visible blink — see Switching Layers Without Visible Glitches for the buffering that hides the seam.
Verify the whole pipeline by polling getStats() and confirming each rid shows independent, growing bytesSent:
// Verification: confirm every layer is independently active before trusting the SFU
const stats = await sender.getStats();
for (const r of stats.values()) {
if (r.type === 'outbound-rtp' && r.rid) {
console.log(`rid=${r.rid} bytesSent=${r.bytesSent} keyframes=${r.keyFramesEncoded} fps=${r.framesPerSecond}`);
// A layer with frozen bytesSent while others grow = collapsed or CPU-starved encoder
}
}
If that loop prints two rows where you configured three, or one row whose bytesSent is pinned while its siblings climb, work through the ordered elimination in Debugging Missing Simulcast Layers before touching the SFU — the layer is almost always dying on the sender.
Keyframe economics and PLI storms
The PLI handshake is cheap in isolation and ruinous in aggregate, and the reason is that a keyframe is not a normal frame. An intra-coded 720p frame typically costs 5–10× a delta frame at the same quality, so a publisher answering a PLI momentarily spends the next second of its budget on one picture. The encoder’s rate controller then claws that back by starving the frames immediately after, which is the soft blur users describe as “it goes fuzzy when someone joins”.
Now scale it. Every subscriber promotion, every join, and every decoder that reports loss generates a PLI toward the same publisher. A 30-person room where people are joining and resizing tiles can easily produce several PLIs per second on the top layer, at which point the publisher is emitting near-continuous keyframes and its effective delta-frame budget collapses. The stream degrades for everyone including the subscribers who were already stable. The fix is a per-layer debounce on the server: coalesce all keyframe requests for the same rid inside a 500 ms–1 s window into a single upstream PLI, and drop requests entirely for a layer where a keyframe is already in flight. Track the ratio of PLIs received to keyframes generated; anything above about 2:1 sustained means your debounce window is too short. The full set of policies, including when to prefer FIR over PLI for recording ingest, is in Keyframe Request Strategies in an SFU.
The second-order effect is on the estimator. Keyframe bursts look like a bitrate spike to transport-wide congestion control, which can read the resulting queueing delay as real congestion and cut the send rate — so a PLI storm not only wastes budget, it actively lowers the estimate that decides whether your top layer stays funded at all. Poll getStats() at 1 s intervals and correlate keyFramesEncoded against targetBitrate per rid; a sawtooth where every keyframe is followed by a dip is this loop, not a network problem.
Edge Cases & Browser Quirks
- Chrome simulcast vs Safari. Chrome (since ~M90) supports three-layer VP8/VP9 simulcast and AV1 SVC reliably. Safari negotiates
a=ridbut is stricter about ordering and historically caps practical simulcast at two layers; if a third layer never produces an SSRC, check that Safari accepted alla=ridlines in the answer rather than pruning one. - VP8 has no spatial SVC. VP8 SVC is temporal-only (
L1T2,L1T3). RequestingL3T3on VP8 silently degrades to a single spatial layer. Use VP9 or AV1 when you need spatial scalability. - H.264 in Chrome maps to SVC. Chromium routes H.264 through its SVC path and will not emit three independent simulcast SSRCs. For three-layer simulcast on H.264 you need an external encoder or a VP-family codec — see VP8 vs H.264 vs AV1 Codec Selection.
- AV1 SVC support is uneven. Chrome ships AV1
L1T3/L3T3; Safari’s AV1 SVC remains partial. Always probeRTCRtpSender.getCapabilities('video')and fall back to VP9 SVC or VP8 simulcast when ascalabilityModeis unsupported, because a rejected mode silently collapses to single-layer encoding. - Firefox simulcast. Firefox supports VP8 simulcast but leans on
scaleResolutionDownByand has historically lagged on VP9/AV1 SVC; treat its SVC support as opportunistic and test the negotiated SDP, not the requested config. - Encodings can only be created at
addTransceivertime. Firefox in particular will not let you grow a one-elementencodingsarray into three viasetParameters()after the fact — the call resolves, the array does not change length, and you get one layer. Chrome tolerates it in some builds, which is worse, because the code appears to work in development and ships broken. Always pass the fullsendEncodingsarray toaddTransceiver(), even when every entry startsactive: false. - Odd capture dimensions break the scaler. A 1280×720 capture divides cleanly by 4, but a webcam that negotiates 1920×1080 gives 480×270 at factor 4, and some hardware encoders require dimensions aligned to 16. Chrome silently rounds, which shifts the effective scale factor away from what you asked for and makes the SFU’s resolution assumptions wrong. Constrain the capture to a resolution divisible by your largest factor rather than trusting the browser to round sensibly.
- Mobile Safari drops layers under thermal pressure. On iOS the encoder is shared with the rest of the system; sustained load makes VideoToolbox reduce its own output before WebRTC’s rate controller reacts, so
framesPerSecondon the top layer sags whiletargetBitratestays high. This reads like network trouble in a dashboard but showsqualityLimitationReason: "cpu"in the stats.
Common Implementation Mistakes
- Omitting
scaleResolutionDownBy. Three identical-resolution encodes triple encoder load for zero benefit and exhaust the CPU within seconds. - Overlapping bitrate tiers. Ceilings packed too closely (400/500/700 kbps) let the estimator merge layers and drop the top one. Keep each layer at least 2× the one below.
- Calling
setParameters()too late. After the first frame encodes, changingridthrowsInvalidModificationError. Configure encodings beforecreateOffer(). - Fighting GCC with manual toggling. Flipping
activefaster than the estimator’s probing cycle causes layer oscillation and packet bursts; only toggle on sustained (>5 s) degradation. - Forwarding an upgrade without a keyframe. Promoting a subscriber mid-GOP renders corruption. Always request a PLI and wait for the keyframe boundary.
- Assuming SVC is universal. Requesting an unsupported
scalabilityModesilently falls back to one layer. Probe capabilities and have a simulcast fallback. - Testing layer counts on a throttled link. The rate allocator will not fund the top layer until the estimate clears the minimums of every layer below it, so a shaped test network reproduces “missing layer” symptoms that have nothing to do with your configuration. Validate three streams on an unconstrained link first.
- Reading
sender.getParameters()as truth. The object echoes what you asked for, not what the encoder is doing. A layer can readactive: truewith amaxBitrateof 1.5 Mbps while producing zero packets. Onlyoutbound-rtpingetStats()tells you a layer exists. - Leaving
maxFramerateunset on the low layer. A 180p layer running at 30 fps spends its whole 150 kbps on temporal detail nobody can see at that size. Capping it at 15 fps roughly halves its cost and frees budget the allocator hands to the tiers above.
FAQ
Should I use simulcast or SVC? Simulcast is the safe default for heterogeneous, cross-browser rooms because every engine supports VP8 simulcast and the SFU logic is trivially simple — forward the matching stream. SVC wins on encoder CPU and total uplink bitrate when your clients reliably run VP9 or AV1, at the cost of an SFU that understands the dependency descriptor. The full trade-off at conference scale is in Choosing Simulcast vs SVC for Large Conferences.
Why does my third simulcast layer never appear?
Almost always the negotiated codec is H.264 (which collapses to SVC in Chrome) or setParameters() ran after encoding began. Confirm the codec in the SDP and that three distinct SSRCs appear under VideoSender in chrome://webrtc-internals.
How does the SFU pick a layer without decoding the video?
For simulcast it keys on the rid/SSRC mapping from the SDP; for SVC it reads the RTP dependency descriptor header extension. In both cases it forwards or drops whole RTP packets and never enters the codec, which is exactly what keeps an SFU cheap relative to an MCU.
What triggers the keyframe before a layer upgrade? The SFU sends an RTCP PLI to the publisher when it decides to promote a subscriber, and holds the higher layer until the keyframe that PLI produces arrives — forwarding earlier shows corruption.
How much uplink does three-layer simulcast actually cost?
The sum of the ceilings, minus whatever the allocator declines to fund — with the 1.5 Mbps / 500 kbps / 150 kbps table that is about 2.15 Mbps of uplink when all three layers are running. Compare that with L3T3 SVC at roughly 1.5 Mbps for the same three qualities, because the layers are cumulative rather than independent. That 30–40% uplink saving is SVC’s headline number, and it is why publishers on asymmetric consumer connections feel the difference first.
Can I run simulcast and SVC on the same track?
Yes, and it is a legitimate configuration: Chrome accepts a per-encoding scalabilityMode inside a RID array, so you can ship three spatial simulcast streams that each carry temporal layers (L1T3 per RID). The SFU then gets coarse resolution choice from the RID and fine frame-rate choice from the temporal index, without needing full spatial SVC support. It costs the same encoder CPU as plain simulcast, since temporal layering is close to free.
Does a paused layer still cost bandwidth?
No. Setting active: false stops that encoder and its RTP stream entirely — no packets, no RTX, no padding — and the freed bitrate is redistributed to the remaining layers within a probing cycle. What it does not do is remove the SSRC or RID from the negotiated session, so the SFU keeps the routing entry and reactivating is instant and renegotiation-free.
Related: return to Media Handling, Codecs & Bandwidth Estimation, drill into Simulcast with Three Quality Layers in Chrome, Choosing Simulcast vs SVC for Large Conferences, and Configuring AV1 SVC Layers in WebRTC, then cross over to Simulcast-Aware Forwarding and Selective Forwarding Unit Design to build the server side.