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.

Simulcast layers feeding an SFU with per-subscriber forwarding A single camera encodes three RID layers — low at 150 kbps, mid at 500 kbps, high at 1.5 Mbps — sent as independent RTP streams to an SFU, which forwards the low layer to a mobile subscriber, the mid layer to a tablet, and the high layer to a desktop based on each subscriber's bandwidth. Camera one encode request rid=high 720p 1.5 Mbps rid=mid 360p 500 kbps rid=low 180p 150 kbps SFU per-subscriber layer select Desktop gets high Tablet gets mid Mobile gets low
One camera emits three RID-tagged RTP streams; the SFU forwards a different spatial layer to each subscriber based on its measured downlink, never re-encoding.

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.

Annotated simulcast SDP m-section The video media section emitted by createOffer, with callouts linking the rtpmap line to setCodecPreferences, the extmap line to the RID header extension, the three a=rid lines to the sendEncodings entries and their role as the SFU forwarding key, and the a=simulcast line to the send order of the encodings array. Video m-section emitted by createOffer() m=video 9 UDP/TLS/RTP/SAVPF 96 a=rtpmap:96 VP8/90000 a=extmap:10 sdes:rtp-stream-id a=rid:high send a=rid:mid send a=rid:low send a=simulcast:send high;mid;low Codec pinned before the offer VP8 keeps layers independent RID header extension tags every RTP packet One a=rid per encoding these strings are the SFU key Send order mirrors the sendEncodings array
Every simulcast line in the offer traces back to one field in 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.

The nine L3T3 operating points A matrix with three spatial rows — S0 at 180p, S1 at 360p, S2 at 720p — crossed with three temporal columns at 7.5, 15 and 30 frames per second, giving nine cumulative bitrate points from 60 kbps up to 1.5 Mbps that an SFU can select per subscriber from a single encode. L3T3 — nine forwardable operating points, one encode T0 · 7.5 fps T1 · 15 fps T2 · 30 fps drop → S2 720p S1 360p S0 180p 600 kbps S2T0 1.0 Mbps S2T1 1.5 Mbps S2T2 full 200 kbps S1T0 340 kbps S1T1 500 kbps S1T2 60 kbps S0T0 base 100 kbps S0T1 150 kbps S0T2 spatial drop ↓ Dropping T2 halves frame rate at the same resolution; dropping S2 halves resolution at the same frame rate. Every cell is decodable on its own from the S0T0 base — the SFU picks one point per subscriber and discards the rest.
The nine L3T3 operating points: temporal drops trade frame rate, spatial drops trade resolution, and the SFU chooses one cell per subscriber without re-encoding.
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.

Keyframe-gated layer upgrade sequence Time flows downward across three lifelines. The subscriber reports a rising downlink estimate to the SFU, the SFU decides to promote it from the low layer to the high layer, sends an RTCP PLI to the publisher, keeps forwarding the low layer while it waits, receives a keyframe on the high layer, and only then switches the subscriber to the high layer. Publisher SFU Subscriber downlink estimate rises to 1.8 Mbps decide: promote low → high RTCP PLI on rid=high meanwhile: still forwarding rid=low keyframe arrives on rid=high switch at the keyframe boundary Forwarding rid=high before the keyframe lands is exactly the green-block corruption case.
An upgrade is a four-step handshake: estimate rises, PLI upstream, keep serving the old layer, switch only on the keyframe.

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

Common Implementation Mistakes

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.