Keyframe Request Strategies in an SFU

An SFU has no encoder, so the only way it can hand a subscriber a decodable starting point is to ask the publisher for one — a Picture Loss Indication (PLI) or Full Intra Request (FIR) travelling upstream over RTCP. This guide is part of the Selective Forwarding Unit Design guide, and it settles one decision: which events actually justify that upstream request, and how to collapse the resulting fan-in so that ten subscribers joining inside 300 ms cost the publisher one intra frame instead of ten.

Context & Trade-offs

Three events legitimately create demand for an intra frame. A new subscriber starts receiving a source: its decoder has no reference frames at all, so every forwarded delta frame is undecodable and the tile stays black until an intra arrives. A layer or source switch: the router begins forwarding a different encoding — a simulcast upswitch driven by the thresholds in Bandwidth-Aware Layer Selection in an SFU, an active-speaker pin, a screen-share promotion — and the new bitstream references frames the subscriber never received. A decoder error: packet loss punched a hole that NACK retransmission could not fill before the jitter buffer’s play-out deadline, and the subscriber emits its own PLI to the SFU.

Those three are not equally urgent, and only the first two are unambiguously the SFU’s problem. A subscriber-originated PLI should first be answered locally: if the missing packets are still in the per-egress send buffer, retransmit them and send nothing upstream. Escalate only after roughly one RTT of failed recovery, because a keyframe is a far more expensive fix than a handful of retransmitted packets.

The cost is the whole reason this page exists. At 720p and a 1.5 Mbps target, a delta frame averages 4–7 KB while an intra frame runs 40–60 KB — roughly eight times larger. Injected into a stream that is already sized to its estimate, the pacer spreads that intra over 100–200 ms, during which the publisher’s instantaneous send rate reaches 2–3x steady state. One keyframe per second lifts the mean uplink rate by 25–30%. Sustained, that overshoot shows up as queueing delay in the very transport-wide feedback the sender’s estimator reads, and the mechanism described in Bandwidth Estimation & Congestion Control responds by lowering the publisher’s allowed bitrate. A keyframe storm is therefore self-defeating: it degrades the stream it was meant to repair.

Trigger Detected by Request type Coalescing key Serve locally first?
Subscriber starts a source SFU router PLI (source, layer) yes — cached intra if < 2 s old
Simulcast layer upswitch Layer selector PLI (source, layer) no — new encoding
SVC layer upswitch Layer selector none yes — subset is decodable
Subscriber PLI after loss Subscriber PLI after 1 RTT (source, layer) yes — NACK from send buffer
Decoder wedged / SSRC reset Subscriber, repeated FIR, seq++ (source) no
Which events earn an upstream keyframe request A decision tree with three branches. A new subscriber checks whether a cached intra is fresh enough and either replays it or sends a debounced PLI. A layer or source switch sends a PLI for simulcast but nothing for an SVC subset. A decoder gap first tries NACK recovery and only sends a PLI, then a FIR, when retransmission fails. A subscriber cannot decode which upstream request, if any? New subscriber joins no reference frames at all Layer or source switch upswitch, pin, screen share Decoder gap subscriber PLI arrives Cached intra under 2 s old? Separate encoding? NACK repaired it in 1 RTT? yes no yes no yes no replay cache no upstream PLI debounce 500 ms PLI one per source SVC subset no request no request loss repaired PLI, then FIR after 2 failures Four of the six leaves cost the publisher nothing — most keyframe demand can be satisfied without leaving the server.
Only two branches reach the publisher; the cheap answers — cached intra, SVC subset, NACK repair — are tried first.

PLI and FIR are not interchangeable. PLI (RTCP payload-specific feedback, format 1) is advisory: “I lost a picture, please refresh.” FIR (format 4) is a command carrying its own sequence number, intended for mixers that need a full encoder reset when a source is repurposed. Repeat a FIR without incrementing that sequence number and receivers ignore it as a duplicate. Use PLI for everything routine and reserve FIR for a wedged decoder that has ignored two PLIs, which also keeps you clear of Firefox’s habit of dropping FIRs it judges redundant.

One structural fact makes the rate limit load-bearing rather than defensive: browser encoders in a WebRTC sender run effectively open-ended groups of pictures. They emit an intra at stream start and then only when asked, precisely because a conversational stream has a feedback channel. The SFU’s request policy therefore is the publisher’s keyframe schedule — there is no periodic safety net quietly refreshing the stream every two seconds the way a broadcast encoder would. That cuts both ways. Ask too often and you own the bitrate overshoot; ask too rarely and a subscriber whose request was throttled has nothing else to wait for. This is also why the budget belongs to the publisher rather than the room: the resource being protected is one encoder’s uplink, and it should be spent on the sources that actually have starving subscribers, not shared with an unrelated participant whose subscribers are churning.

Minimal Runnable Implementation

The gate below sits between the router and the publisher’s RTCP sender. It applies three filters in order: deduplicate by (source, layer), debounce with both a leading and a trailing edge, then spend a token from a per-publisher bucket so a pathological room cannot exceed a fixed keyframe budget.

// Per-publisher keyframe gate: dedup -> debounce -> token bucket -> RTCP.
class KeyframeGate {
  constructor(publisher, opts = {}) {
    this.publisher = publisher;
    this.debounceMs = opts.debounceMs ?? 500;  // at most one intra per key per window
    this.capacity = opts.capacity ?? 3;        // burst: joins arrive in clumps, not evenly
    this.refillMs = opts.refillMs ?? 2000;     // sustained ceiling: 1 keyframe / 2 s
    this.tokens = this.capacity;
    this.lastRefill = Date.now();
    this.windows = new Map();                  // key -> { openedAt, pending }
    this.firSeq = 0;                           // FIR seq MUST increment or receivers ignore it
    this.stats = { asked: 0, sent: 0, deduped: 0, throttled: 0 };
  }

  refill(now) {
    const earned = Math.floor((now - this.lastRefill) / this.refillMs);
    if (earned > 0) {
      this.tokens = Math.min(this.capacity, this.tokens + earned);
      this.lastRefill += earned * this.refillMs; // keep the remainder, don't drift
    }
  }

  // key is a stable string per (sourceSsrc, layerIndex); reason is used only for logging/FIR.
  request(key, reason, now = Date.now()) {
    this.stats.asked++;
    const w = this.windows.get(key);
    if (w && now - w.openedAt < this.debounceMs) {
      w.pending = true;                        // an intra is already in flight for this key
      this.stats.deduped++;
      return false;                            // the trailing flush covers late arrivals
    }
    this.refill(now);
    if (this.tokens < 1) {
      this.stats.throttled++;                  // budget exhausted: the room is churning
      return false;                            // caller must not retry in a tight loop
    }
    this.tokens -= 1;
    this.windows.set(key, { openedAt: now, pending: false });
    this.emit(key, reason);
    setTimeout(() => this.flush(key), this.debounceMs); // trailing edge
    return true;
  }

  emit(key, reason) {
    // PLI for everything routine; FIR only for a decoder that ignored earlier PLIs.
    if (reason === 'decoder-reset') this.publisher.sendRtcpFir(key.ssrc, this.firSeq++);
    else this.publisher.sendRtcpPli(key.ssrc);
    this.stats.sent++;
  }

  flush(key) {
    const w = this.windows.get(key);
    if (!w) return;
    this.windows.delete(key);
    // Someone asked mid-window: they joined after the intra was already sent, so serve them now.
    if (w.pending) this.request(key, 'trailing-edge');
  }
}
Request fan-in reduced by three successive gates Forty-two subscriber events over three seconds enter a dedup stage keyed by source and layer, which absorbs thirty-one duplicates. Eleven survivors reach a five-hundred millisecond debounce that absorbs seven more. Four reach a token bucket holding three tokens refilled one per two seconds, which throttles two, leaving two PLIs sent upstream to the publisher. Fan-in reduction across one publisher, 3 s of a 10-person join wave Counts on the arrows are requests still alive after each gate. Events joins + switches + subscriber PLI Dedup key = src + layer one in flight Debounce 500 ms window leading + trailing Token bucket cap 3 refill 1 / 2 s Upstream 2 PLI sent to publisher 42 11 4 2 31 duplicate keys 7 inside window 2 over budget 1 of 3 tokens left Dedup and debounce absorb bursts; the bucket bounds the sustained rate, so no room shape can cost more than 1 intra / 2 s per source. Throttled requests are counted, never retried in a loop — the trailing edge of the next window picks them up.
Three gates in series turn a 42-request join wave into two upstream PLIs, with an explicit counter behind every discarded request.

Make the thresholds configuration rather than constants, because a webinar with 500 passive viewers and a six-person standup want different budgets:

[keyframe]
; one intra per source per window; under ~300 ms the encoder never reaches steady state
debounce_ms = 500
; burst allowance so a ten-person join wave is not serialised over twenty seconds
bucket_capacity = 3
; sustained ceiling per publisher, per layer
bucket_refill_ms = 2000
; replay a cached intra to a joiner instead of asking upstream, if it is this fresh
keyframe_cache_max_age_ms = 2000
; escalate PLI to FIR only after this many unanswered requests
fir_after_unanswered_pli = 2

The cache line is the highest-leverage entry. Holding the most recent intra per layer costs about 40–60 KB of memory per layer per publisher and removes the upstream request entirely for any subscriber that joins within the window — the SFU replays the cached frame into that subscriber’s sequence space, then continues from live packets. It only works if you renumber the injected packets correctly, which is the same rewriting discipline covered in Rewriting RTP Header Extensions When Forwarding.

Reproduction Steps & Debugging Log Patterns

  1. Publish a three-layer 720p stream and subscribe one client. Poll getStats() at 1 s intervals on the publisher and record outbound-rtp.pliCount, firCount, keyFramesEncoded and bytesSent as your baseline.
  2. Join ten subscribers within 300 ms, all onto the same layer. Correct behaviour: pliCount rises by exactly 1 and keyFramesEncoded by 1. A rise of 10 means the gate is keyed per subscriber rather than per source.
  3. Watch bytesSent in the 1 s bucket containing that intra. Expect roughly a 25–30% bump over the neighbouring seconds — that is the keyframe, not a bug.
  4. Script 200 layer switches per second against one publisher for 10 s. The bucket should cap output near 5 PLIs and the throttled counter should climb steadily rather than the process spinning.
  5. Drop 5% of packets on one subscriber’s downlink. Verify NACK retransmissions rise first and pliCount stays flat, confirming loss is repaired from the send buffer before any escalation.
What one keyframe costs the publisher's uplink Thirty bars represent one second of encoded frames at thirty frames per second. Twenty-nine delta frames measure between four and seven kilobytes; the first bar is an intra frame of forty-eight kilobytes. Annotations give the totals with and without the intra and the peak rate during the pacer window. Encoded frame sizes: 720p, 30 fps, 1.5 Mbps target One PLI answered inside this second produces the single purple bar. 0 10 20 30 40 50 KB 48 KB 29 deltas only: 158 KB, about 1.26 Mbps with 1 intra: 201 KB, about 1.61 Mbps (+28%) peak inside the 150 ms pacer window: ~3.7 Mbps 1 10 20 30 frame index (one second at 30 fps) pacer spreads the intra over ~150 ms — this is the burst the estimator sees Two un-coalesced keyframes in the same second push the publisher past 2 Mbps and its estimate falls in response.
One intra frame is worth roughly eight delta frames; the damage is not the average but the 150 ms burst the congestion controller reads as queueing delay.

Instrument the gate itself rather than inferring its behaviour from stats alone: log one line per request() with the coalescing key, the reason, the outcome (sent, deduped, throttled) and the remaining token count. Those four fields turn every keyframe question into a grep, and they let you replay a bad minute offline without a packet capture. The contrast between a healthy and a mis-keyed gate is immediate:

// Healthy gate log for step 2 — ten joins, one intra.
// [pub=P1 key=ssrc7:l1] request reason=subscriber-join  -> PLI sent, tokens=2
// [pub=P1 key=ssrc7:l1] request reason=subscriber-join  -> deduped (in-window, 8 more)
// [pub=P1 key=ssrc7:l1] window closed pending=true      -> trailing PLI, tokens=1
// [pub=P1] keyFramesEncoded 41 -> 42, bytesSent/s 158k -> 201k

// Pathological gate log — the key includes the subscriber id, so nothing ever coalesces.
// [pub=P1 key=ssrc7:l1:subA] request -> PLI sent, tokens=2
// [pub=P1 key=ssrc7:l1:subB] request -> PLI sent, tokens=1
// [pub=P1 key=ssrc7:l1:subC] request -> throttled (tokens=0)   // budget burned in 40 ms
// [pub=P1] keyFramesEncoded 41 -> 44, bytesSent/s 158k -> 297k

The diagnostic tell of a mis-keyed gate is sent tracking asked almost one-for-one with deduped near zero. The tell of an over-tight budget is a throttled counter that never returns to zero and subscribers reporting long black tiles; cross-check against subscriber-side framesDecoded stalling while packetsReceived climbs, and read the congestion side of the same capture using the patterns in Interpreting getStats() for Congestion Signals.

Common Implementation Mistakes

FAQ

PLI or FIR — which should the SFU send?

PLI for essentially everything: joins, layer switches and unrecoverable loss. FIR is a command aimed at full encoder state resets and carries a sequence number that must increment on every send; reserve it for a decoder that has ignored two PLIs. Chrome answers both, while Firefox may discard a FIR it considers redundant, so a FIR-first strategy produces silent no-ops.

How long can a joining subscriber wait for its first frame?

Budget 200–500 ms from subscription to first decodable frame. A cached intra replayed from the server hits the low end because nothing leaves the machine; an upstream PLI adds one server-to-publisher RTT plus encoder latency, so a 500 ms debounce puts the worst case near 700 ms. Beyond about 1 s users read it as a broken tile, which is the threshold worth wiring into the SLOs in Alerting on Freeze and Packet-Loss SLOs.

Can rate limiting leave a subscriber frozen forever?

Not if the bucket has a trailing edge and every publisher still emits periodic keyframes. A throttled request is recorded, not discarded outright, and the next refill or the encoder’s own periodic intra resolves it within a couple of seconds. What you must avoid is a caller that retries in a tight loop on refusal — that turns a bounded delay into a busy spin without producing a single extra frame.

Related: this deep-dive sits under Selective Forwarding Unit Design; pair it with Simulcast-Aware Forwarding for the room-wide layer policy that generates most of these requests, and with SFU vs MCU Topologies for why a forwarding server has to ask the publisher at all.