Simulcast-Aware Forwarding

A Selective Forwarding Unit earns its name by never re-encoding media: it receives one set of RTP streams from each publisher and forwards a subset to each subscriber untouched. Simulcast turns that forwarding decision into the core of the product. When a publisher sends three independently encoded resolutions of the same camera — a high, a medium, and a low spatial layer, each its own RTP stream with its own SSRC — the server must choose, per subscriber, which one of those streams to relay, and switch between them as that subscriber’s downlink changes. This guide is part of the Media Server Architecture: SFU & MCU guide, and it covers the exact mechanics of that forwarding path: reading the RID/MID that identifies each layer, mapping a subscriber’s estimated bitrate to a layer, switching cleanly on keyframe boundaries, and rewriting the RTP headers so the receiver never notices the source changed.

The goal is concrete: build a forwarder that can promote a subscriber from the low layer to the high layer when their bandwidth recovers, and demote them again when it drops, with no decoder corruption, no frozen frame, and a switch latency bounded by one keyframe interval. Everything below assumes you have already terminated DTLS-SRTP, demultiplexed RTP, and parsed RTCP feedback — the forwarding logic sits one layer above that.

Simulcast-aware forwarding in an SFU One publisher sends three simulcast layers — high, medium, and low — into an SFU layer selector. The selector forwards the high layer to a subscriber on fast Wi-Fi, the medium layer to a subscriber on a stable connection, and the low layer to a subscriber on a congested mobile link. Publisher one camera High — 1280x720 rid=h ~1700 kbps Medium — 640x360 rid=m ~500 kbps Low — 320x180 rid=l ~180 kbps SFU layer selector Subscriber A — Wi-Fi gets High Subscriber B — stable gets Medium Subscriber C — mobile gets Low Per subscriber, the selector picks exactly one layer and rewrites SSRC, sequence number, and timestamp so the chosen stream looks continuous. A bitrate change triggers a switch on the next keyframe of the target layer.
The publisher emits three independent simulcast streams; the SFU selector forwards exactly one to each subscriber and rewrites RTP headers so each switch is invisible to the decoder.

Step 1 — Read RID and MID to identify the incoming layers

Simulcast layers arrive as separate RTP streams that all belong to the same logical track. The browser tags each one with an RTP Stream Identifier (RID) carried in the urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id header extension, and associates them with a media section through the MID extension (urn:ietf:params:rtp-hdrext:sdes:mid). The publisher’s SDP declares which RIDs exist and in what order via a=simulcast:send and a=rid: lines; your job is to parse those and then match each inbound packet to a layer by reading its RID extension.

Chrome sends the RID only on the first few packets of each stream and on every keyframe, not on every packet — so cache the SSRC-to-RID binding the moment you first observe it, and never assume a later packet will re-advertise the RID. Once you know ssrc → rid, and rid → layer index from the SDP, you have the mapping the selector needs.

// Parse the publisher's simulcast layers from the offer SDP, then bind SSRCs at runtime.
// rid order in `a=simulcast:send h;m;l` is high→low by convention but NOT guaranteed —
// resolve the real spatial size from each a=rid line's max-width/max-height if present.
function parseSimulcastLayers(sdpMediaSection) {
  const rids = [];
  for (const line of sdpMediaSection.split(/\r?\n/)) {
    const m = line.match(/^a=rid:(\S+) send/); // a=rid:h send max-width=1280;max-height=720
    if (m) rids.push(m[1]);
  }
  return rids; // e.g. ['h', 'm', 'l'] — index 0 is the top layer
}

const ssrcToRid = new Map();   // learned from the RID header extension on early/keyframe packets
const ridToIndex = new Map();  // 'h'->0, 'm'->1, 'l'->2 from parseSimulcastLayers order

function onRtpPacket(pkt) {
  const rid = pkt.getHeaderExtension('rtp-stream-id'); // present early + on keyframes only
  if (rid && !ssrcToRid.has(pkt.ssrc)) {
    ssrcToRid.set(pkt.ssrc, rid); // bind once; later packets omit the RID
  }
  const layerIndex = ridToIndex.get(ssrcToRid.get(pkt.ssrc));
  routePacket(pkt, layerIndex); // hand to the per-subscriber selector
}

Binding layers when BUNDLE and RTX are in play

A production forwarder never sees a tidy set of three SSRCs on the wire. With BUNDLE every audio and video stream in the session shares one 5-tuple, so demultiplexing is a two-stage lookup: the MID extension tells you which m-section — which transceiver — a packet belongs to, and only within that section does the RID distinguish the three simulcast layers. Skip the MID stage and a screen-share track’s RID h will collide with the camera track’s RID h, because RID values are only unique per m-section, not per session.

Retransmission doubles the SSRC count again. When the publisher negotiates a=rtcp-fb:96 nack alongside a=fmtp:97 apt=96, each layer gets a paired RTX stream with its own SSRC, so a three-layer video publisher occupies six video SSRCs on one transport. RTX packets carry the original sequence number in the first two bytes of the payload and frequently carry no RID extension at all; you bind them through a=ssrc-group:FID <media> <rtx> when the SDP declares SSRCs, and otherwise by observing which media SSRC each RTX stream repairs. Treat RTX SSRCs as unknown layers and you will either forward duplicate media or discard legitimate repairs.

With RID-based simulcast, though, Chrome emits no a=ssrc lines for the layers at all — the SDP names RIDs and nothing else, so a binding table preloaded from the offer is empty and the mapping exists only at runtime. The publisher-side configuration that produces these lines is walked through in Simulcast with Three Quality Layers in Chrome. The table must therefore be built lazily and must also expire: age out an ssrc → rid entry after roughly 5 s of silence, because a publisher that renegotiates or restarts its encoder can recycle an SSRC into a different layer, and a stale binding routes high-layer packets into a low-layer subscriber’s stream with no error raised anywhere in the pipeline.

Step 2 — Identify spatial and temporal layers within each stream

Simulcast gives you spatial layers — distinct resolutions, each its own RTP stream. Inside each of those streams the encoder usually also produces temporal layers: a base layer at, say, 7.5 fps and additional frames that lift it to 15 and 30 fps, all in one RTP stream and distinguishable only by reading the codec payload. Temporal scalability is what lets you drop a subscriber’s frame rate without a full layer switch, shedding 30–50% of a stream’s bitrate by forwarding only the lower temporal IDs.

Temporal layer pattern inside one spatial layer Frames of a single simulcast stream arrive in a repeating temporal-ID pattern of 0, 2, 1, 2. Forwarding all frames yields 30 fps; dropping temporal ID 2 leaves 15 fps and saves about 30 percent of the bitrate; dropping temporal IDs 1 and 2 leaves the 7.5 fps base layer and saves about 50 percent. One spatial layer (rid=m, 640x360) — frames in arrival order TID 2 top +15 fps TID 1 mid +7.5 fps TID 0 base 7.5 fps 0 2 1 2 0 2 1 2 0 2 1 2 time Forward TID 0+1+2 30 fps, full layer bitrate baseline for this stream Drop TID 2 15 fps, about 30% saved no spatial switch needed Drop TID 1 and 2 7.5 fps, about 50% saved TID 0 must never be cut
Temporal shedding inside one spatial layer: the repeating 0-2-1-2 pattern lets the forwarder halve or quarter the frame rate by dropping high temporal IDs, without ever touching the base layer.

How you read the temporal ID depends entirely on the codec. VP8 exposes a TID field plus a picture-id in its payload descriptor; AV1 carries a Dependency Descriptor header extension that encodes the full spatial/temporal dependency graph, which is the same structure the Configuring AV1 SVC Layers in WebRTC workflow relies on. Mishandling this distinction is the single most common source of decoder corruption, so the forwarder must know, per codec, exactly which bytes carry the temporal ID and which frames are safe to drop.

// Extract the temporal ID per codec. Dropping a higher-TID frame is always safe;
// dropping a base-layer (TID 0) frame breaks every frame that depends on it.
function temporalId(pkt, codec) {
  if (codec === 'VP8') {
    // VP8 payload descriptor: T bit signals presence of TID in the extension byte
    const d = pkt.payloadDescriptor;
    return d.hasTID ? d.tid : 0;        // 0 = base layer, must always be forwarded
  }
  if (codec === 'AV1') {
    // AV1 reads the temporal_id straight from the Dependency Descriptor extension
    return pkt.dependencyDescriptor.temporalId;
  }
  return 0; // H.264 simulcast here is treated as spatial-only (no temporal shaping)
}

Temporal shedding is the cheaper adjustment and should always be tried first. Dropping a high temporal ID needs no keyframe and no decoder reset, so it takes effect on the very next frame instead of after a keyframe round trip, and it holds resolution constant — a frame-rate dip is far less noticeable than a resolution jump, which reads as the picture visibly softening. The two mechanisms also work at different scales. With layers at 1/2 and 1/4 scale, high to medium sheds roughly 70% of the bitrate (about 1700 kbps down to 500 kbps) and medium to low another 64% (500 kbps to 180 kbps); the 30% and 50% temporal steps sit inside those gaps, turning a coarse 3-rung ladder into 7–9 usable rungs and letting the selector track a wobbling estimate without a spatial switch every few seconds.

One VP8 caveat governs where you are allowed to make the cut. In the default three-layer pattern only TID 2 frames are truly non-reference; TID 1 frames are referenced by later TID 1 frames through the golden-frame buffer. Start dropping TID 1 in the middle of a 0-2-1-2 cycle and the next TID 1 frame decodes against a picture the subscriber never received. Apply a change to the temporal cap at a pattern boundary — the frame where the next TID 0 arrives — so the dependency chain you are cutting is already closed.

Step 3 — Map subscriber bitrate to a layer and switch on a keyframe

Each subscriber has an estimated downlink bitrate from REMB or transport-wide congestion control feedback — the same estimate produced by the bandwidth estimation pipeline. The selector maps that estimate onto a target layer using each layer’s measured send bitrate plus a safety margin, then commits to the switch only when a usable decoder-refresh point arrives. The full threshold table, debounce timing, and keyframe-request logic are worked out in Forwarding Simulcast Layers by Subscriber Bandwidth; the broader policy that balances every subscriber against the publisher’s available layers lives in Bandwidth-Aware Layer Selection in an SFU.

The non-negotiable rule: you may only begin forwarding a new spatial layer starting at a keyframe of that layer. Inter-coded frames reference earlier frames of the same stream; splice a P-frame from the high layer onto a decoder that was watching the low layer and you get a green-block smear or a hard freeze until the next keyframe. When you decide to upswitch, send an RTCP Picture Loss Indication (PLI) or Full Intra Request (FIR) to the publisher for the target layer’s SSRC, keep forwarding the old layer until the requested keyframe arrives, and only then cut over. Downswitching to a lower layer that is already flowing can often happen on its next existing keyframe without a request, since lower layers are cheaper for the publisher to refresh frequently. The perceptual side of that cutover — resolution jumps, buffer timing, and how long the eye tolerates the transition — is worked through in Switching Layers Without Visible Glitches.

Per-subscriber layer switch state machine The forwarder sits in FORWARDING low, moves to PENDING upswitch when the estimate rises and a PLI is sent, retries the PLI on a one second timeout up to three times, commits to FORWARDING high when the target layer keyframe arrives, and returns to the low layer on the next low keyframe when the estimate drops. One subscriber, one publisher track — the selector holds exactly one state FORWARDING low rid=l, 320x180 PENDING upswitch low still flowing FORWARDING high rid=h, 1280x720 estimate rises send 1 PLI keyframe of h commit timeout 1 s, retry PLI, max 3 estimate drops — switch on the next low keyframe, no PLI needed 3 retries exhausted — abandon Nothing is dropped while PENDING: the old layer keeps flowing, so the subscriber never sees a freeze.
The switch is a three-state machine — the pending state exists purely so the old layer keeps flowing until the target layer's keyframe lands.
// Per-subscriber forwarder. Switches are pending until a keyframe of the target layer lands.
class SubscriberForwarder {
  constructor(publisher, sendPli) {
    this.publisher = publisher;
    this.sendPli = sendPli;        // (ssrc) => emit RTCP PLI/FIR upstream
    this.currentLayer = 2;         // start conservative on the low layer
    this.pendingLayer = null;
  }

  requestLayer(target) {
    if (target === this.currentLayer || target === this.pendingLayer) return;
    this.pendingLayer = target;
    // ask the publisher for a fresh keyframe on the layer we want to switch into
    this.sendPli(this.publisher.ssrcForLayer(target));
  }

  forward(pkt, layerIndex, isKeyframe) {
    if (this.pendingLayer !== null && layerIndex === this.pendingLayer && isKeyframe) {
      this.currentLayer = this.pendingLayer; // commit exactly on the keyframe boundary
      this.pendingLayer = null;
    }
    if (layerIndex !== this.currentLayer) return; // drop every other layer's packets
    this.rewriteAndSend(pkt);
  }
}

Hysteresis — why promotion is slow and demotion is instant

The mapping from estimate to layer must never be a bare comparison, because the estimate is noisy at the 1 s granularity you poll it at. Make the thresholds asymmetric: require the estimate to exceed the target layer’s measured send bitrate by about 30% and hold there across four consecutive samples before promoting, but demote on the first sample that falls below the current layer’s bitrate. That mirrors the cost asymmetry — sitting one layer too low costs sharpness most viewers never consciously notice, while sitting one layer too high fills the subscriber’s downstream queue, and the resulting queueing delay, loss and freezing is immediately obvious.

One subtlety trips up nearly every first implementation: while a subscriber is pinned to the 180 kbps low layer, its congestion controller has no traffic to probe with, so the estimate plateaus just above the current send rate and the promotion threshold is never crossed — the stream stays at 320x180 on a link that could carry 5 Mbps. Breaking the deadlock requires active probing, padding packets or an RTX burst that briefly lifts the send rate above the current layer so the estimator can observe whether delay increases. That is one of the practical differences explored in Transport-CC vs REMB Feedback: transport-wide feedback reports per-packet arrival times, so a 200–300 ms probe confirms headroom, whereas REMB’s receiver-side estimate reacts more slowly and needs the probe sustained for a second or more.

Oscillation also has a direct bitrate cost. A 720p keyframe is typically 5–10x the size of a P-frame, so every upswitch injects a burst of tens of kilobytes into a link you have just judged marginal; a selector that flaps at 1 Hz can manufacture the very loss that triggers its next demotion.

# Per-subscriber layer selector thresholds — tune these before touching the estimator
upswitch_margin        = 1.3    # estimate must exceed target layer's measured bitrate by 30%
upswitch_hold_samples  = 4      # sustained across 4 samples of 1 s getStats polling
downswitch_margin      = 1.0    # demote as soon as the estimate drops under the current layer
downswitch_hold_ms     = 0      # no debounce on the way down — congestion is not a false alarm
temporal_first         = true   # shed TID before attempting a spatial demotion
pli_retry_ms           = 1000   # one PLI, then retry after 1 s
pli_max_retries        = 3      # abandon the pending upswitch after 3 attempts
layer_dead_ms          = 1000   # no packets on a layer SSRC for 1 s -> treat the layer as absent

Step 4 — Rewrite RTP SSRC, sequence number, and picture-id, then verify

From the subscriber’s decoder’s point of view there is a single continuous RTP stream. But behind the selector you are splicing packets from streams that each have their own SSRC, their own monotonically increasing sequence numbers, and their own timestamp and picture-id baselines. Forward them raw and the receiver sees the SSRC change (it tears down and rebuilds the stream), sees a sequence-number discontinuity (it reports massive packet loss), and sees the picture-id jump (VP8 reference picture selection breaks). The forwarder must therefore present one outgoing SSRC and rewrite every header field to a continuous, gap-free sequence.

Maintain per-subscriber offsets: a fixed output SSRC, a running sequence-number translation that closes the gap left by every dropped packet, and a picture-id translation for VP8. At each switch you snapshot the last values you emitted and rebase the new layer onto them. The header extensions riding alongside these fields — RID, MID, abs-send-time, transport-wide sequence number — need their own rewrite pass, which is covered in Rewriting RTP Header Extensions When Forwarding. Verify the result by pulling the subscriber’s inbound-rtp stats — framesDecoded should keep climbing across a switch, freezeCount should not increment, and pliCount upstream should show exactly one request per upswitch, not a storm.

RTP header rewrite across a layer splice The first packet of the new high layer carries its own SSRC, sequence number, timestamp and VP8 picture id. The forwarder replaces the SSRC with the single per-subscriber output SSRC, adds a sequence offset so numbering continues gap-free, rebases the timestamp onto the subscriber clock, and rebases the VP8 picture id. First packet of rid=h after commit What the subscriber receives SSRC 0x7A1C3B90 sequence number 41022 timestamp (90 kHz) 3214500 VP8 picture-id (15-bit) 8190 SSRC (fixed per subscriber) 0x11EE0042 sequence number 22401 — no gap timestamp (90 kHz) 1876230 — monotonic VP8 picture-id (15-bit) 5042 — continues replace with outSsrc + seqOffset, mask 0xffff + tsOffset, one clock + picIdOffset, mask 0x7fff Offsets are recomputed once, at commit. Every packet dropped by temporal shedding also advances seqOffset, otherwise the receiver reports the shed frames as packet loss and starts NACKing for packets that never existed.
Four header fields change on every forwarded packet; only the offsets behind them change at a switch, which is what makes the splice invisible to the receiver.
// Rewrite headers so the splice is invisible. One output SSRC per subscriber.
rewriteAndSend(pkt) {
  const t = this.translation;                 // { outSsrc, seqOffset, lastSeq, picIdOffset }
  pkt.ssrc = t.outSsrc;                        // collapse N source SSRCs into one
  pkt.sequenceNumber = (pkt.sequenceNumber + t.seqOffset) & 0xffff; // gap-free, 16-bit wrap
  t.lastSeq = pkt.sequenceNumber;

  if (pkt.codec === 'VP8') {
    // rebase VP8 picture-id so reference selection stays monotonic across a layer switch
    pkt.payloadDescriptor.pictureId = (pkt.payloadDescriptor.pictureId + t.picIdOffset) & 0x7fff;
  }
  this.send(pkt);
}

// On commit, recompute offsets so the NEW layer continues from the last emitted values.
rebaseOnSwitch(firstPktOfNewLayer) {
  const t = this.translation;
  t.seqOffset = (t.lastSeq + 1 - firstPktOfNewLayer.sequenceNumber) & 0xffff;
  t.picIdOffset = (t.lastPicId + 1 - firstPktOfNewLayer.payloadDescriptor.pictureId) & 0x7fff;
}

What to instrument on the forwarding path

Subscriber-side getStats() reports the outcome but not the decision, so the selector needs its own telemetry. Export, per subscriber and per publisher track, a gauge for the current spatial index and temporal cap, counters for committed switches and PLIs emitted, and a histogram of the wall time between sending a PLI and committing on the resulting keyframe. Add one derived ratio — bytes forwarded over bytes received for that publisher — which sits between 1/3 and 1/10 in a healthy conference; a value approaching 1 means the selector is handing the top layer to everyone and the thresholds are wrong.

Fewer than 2 committed switches per minute per subscriber is normal; more than 6 means the hysteresis window is too tight for that population’s networks. Keyframe wait p95 should stay under 300 ms — beyond that the fault is usually a publisher deferring intra requests under thermal load rather than your forwarder. The selector itself must stay inside the 1–3 ms of forwarding overhead an SFU is allowed to add, so time the per-packet path and treat drift toward the 80–200 ms a transcoding path costs as a hot-loop bug, typically a per-packet allocation. Wiring these series into dashboards and alert rules is covered in Exporting SFU Metrics to Prometheus and Grafana.

Edge Cases & Browser Quirks

The keyframe thundering herd

The nastiest failure here is correctness-preserving and still takes down the call. Picture 50 subscribers watching one publisher when a shared bottleneck clears — a congested Wi-Fi access point drains, or a transit path reroutes. Every estimate rises inside the same 1 s window, every selector independently decides to promote, and every one sends a PLI for the high layer’s SSRC. libwebrtc honours intra requests eagerly, so the publisher emits back-to-back keyframes and its uplink spikes 3–5x above target for a second or more, re-congesting the path that just recovered. Estimates collapse, all 50 selectors demote, and the room settles into a synchronised sawtooth.

The fix is coalescing rather than rate limiting. Because the commit rule is per-subscriber and stateless — commit on the next keyframe of the target layer — one keyframe satisfies any number of pending upswitches for free. Put a token bucket keyed by (publisher, layer) in front of the upstream request path, allow at most one intra request per layer per 500 ms, and let every pending switch resolve against whichever keyframe arrives. Spreading the promotion decision itself over a random 100–300 ms desynchronises the herd before it ever reaches the bucket.

Common Implementation Mistakes

FAQ

Does simulcast-aware forwarding re-encode video? No. The defining property of this design is that media is forwarded byte-for-byte at the codec level; only RTP header fields and codec-specific descriptors are rewritten. There is no transcode, no pixel work, and therefore no per-stream CPU cost beyond packet rewriting — which is exactly why an SFU scales where an MCU does not. The topology trade-off is detailed in SFU vs MCU Topologies.

How fast can a subscriber switch from the low layer to the high layer? Switch latency is bounded by how quickly the publisher produces a keyframe for the target layer after your PLI. With a sane keyframe-on-request path that is typically one round trip plus encoder latency — tens to low hundreds of milliseconds. You keep forwarding the old layer the entire time, so the subscriber sees continuous video, just at the old quality until the cutover.

Why not just forward all layers and let the client pick? That defeats the purpose: forwarding every layer to every subscriber sends the full aggregate bitrate down each link, which is precisely the congestion simulcast exists to avoid. The server-side selection is what keeps each subscriber’s downlink matched to one layer.

How does this differ from SVC forwarding? With SVC the layers are encoded with dependencies inside a single stream rather than as independent simulcast streams, so the forwarder drops the unwanted enhancement layers of one stream instead of choosing between separate streams. The decision between the two encodings is covered in Choosing Simulcast vs SVC for Large Conferences.

What happens when a subscriber joins mid-call? A joining subscriber’s decoder has an empty reference buffer, so the first packet you forward has to be part of a keyframe or the picture never resolves — feeding it P-frames produces grey or green output that some decoders will not recover from before the next intra anyway. Start the subscriber on the low layer, request one keyframe for it, and hold the outbound stream silent until that keyframe lands; low-layer keyframes are the cheapest for the publisher to produce and typically arrive well inside a second. Only after the subscriber is decoding cleanly should the normal promotion path be allowed to run, otherwise the first estimate sample fires an upswitch before there is a stable picture to switch away from.

How much of the payload does the forwarder actually parse? Only the payload descriptor — for VP8 the first 1–6 bytes carrying the extension flags, picture-id and TID, and for AV1 the Dependency Descriptor riding in the RTP header extension. The compressed frame data is never touched, which is why the forwarder is indifferent to resolution, codec profile and bitrate, and why the whole path adds 1–3 ms of latency against the 80–200 ms a transcoding server spends per stream. It also means the forwarder cannot repair a stream it does not understand: an unknown codec can be relayed spatially but gets no temporal shedding at all, because the bytes that would tell you which frames are safe to drop are opaque.

Related: build the per-subscriber threshold logic in Forwarding Simulcast Layers by Subscriber Bandwidth, place it inside the broader Selective Forwarding Unit Design and its Bandwidth-Aware Layer Selection in an SFU policy, and revisit the client-side encoding setup in Simulcast & SVC Implementation.