Autoscaling SFU Nodes on CPU and Bandwidth

An autoscaler that watches CPU will add capacity to a Selective Forwarding Unit tier several minutes after the first participant has already started freezing. This guide is part of the Load Balancing & Scaling SFUs guide, and it settles one operational question: which numbers should drive desiredCount on a tier whose nodes hold live, unmovable sessions, and how do you remove a node from that tier without cutting a call. The answer is a composite load score built from outbound bitrate and forwarded-stream count, with CPU demoted to a guardrail, plus a two-phase removal β€” cordon, then drain.

Context & Trade-offs

An SFU relays packets; it does not decode or re-encode them. Forwarding a stream costs roughly 1–3 ms of packet handling versus the 80–200 ms of transcode work an MCU spends per composed output, which is exactly why the CPU number is uninformative. On a 2 vCPU node with a 1 Gbps interface, SRTP encryption and the NACK retransmission cache typically hold CPU between 20% and 30% while the interface is already pushing 900 Mbps. The box is finished; the metric your cloud provider hands you by default says it is idle.

Two signals actually track exhaustion. The first is outbound bitrate β€” the sum of bytesSent deltas across every outbound-rtp the node maintains, sampled on the same 1 s cadence used everywhere else in this stack. It must be measured, not estimated from participant counts, because what a node really sends depends on which simulcast layer each subscriber is receiving, the selection logic covered in Bandwidth-Aware Layer Selection in an SFU. The second is forwarded-stream count, because a large share of per-node work is fixed per stream rather than per byte: header rewriting, sequence-number continuity, one SRTP context, one jitter-buffer-adjacent bookkeeping structure. A node carrying 900 audio-only streams at 32 kbps each occupies barely 30 Mbps but has 900 sets of per-stream state, and it will fall over on stream count long before bitrate looks interesting.

Egress and CPU diverge across a 40-minute ramp A plot with time on the horizontal axis from zero to forty minutes and utilisation on the vertical axis from zero to one hundred percent. The egress series rises steeply and crosses the sixty-five percent scale-out trigger at about twenty-one minutes, passing the eighty-five percent safe ceiling near minute thirty-two. The CPU series stays almost flat, reaching only twenty-nine percent at minute forty, so a CPU-driven policy never fires. One 40-minute ramp: egress saturates the NIC, CPU barely moves 100% 75% 50% 25% 0 safe egress ceiling 85% scale-out trigger 65% +1 node fires here, t+21 min egress % of NIC CPU % (2 vCPU node) 0 10 20 30 40 min A CPU threshold of 70% is never reached β€” the node dies at 100% egress instead
The two curves come from the same node in the same hour; only one of them knows the node is nearly full.
Signal What it catches Where it lies
Outbound bitrate video-heavy rooms filling the NIC audio-only tiers look empty at 30 Mbps
Forwarded streams per-stream state and SRTP contexts one 4 Mbps screen share counts as 1
CPU encryption + NACK cache saturation flat at 20–30% until egress is already gone
Admission failures ceilings already breached too late to be a primary trigger

Both ceilings have to be measured on your own hardware before they mean anything. Set the egress ceiling at roughly 70% of the interface rate β€” 700 Mbps on a 1 Gbps NIC β€” because the remaining headroom absorbs keyframe bursts, and a keyframe storm after a layer switch can add 30–40% to instantaneous egress for a second or two. The stream ceiling comes from a load test rather than arithmetic: push forwarded streams up in steps of 100 until the node’s own forwarding delay leaves the 1–3 ms band, and take 80% of the count where it broke. A node with a 700 Mbps egress ceiling and a 1,200-stream ceiling behaves completely differently in a 40-person video meeting (bitrate-bound) than in a 600-seat webinar with audience audio (stream-bound), and the composite score is what lets one policy serve both.

Take the max of the normalised signals rather than an average: an average of a 0.92 egress ratio and a 0.15 stream ratio reads as a comfortable 0.53 and never scales. The remaining trade-off is timing, and it is specific to stateful media. A new node absorbs no existing load β€” placement is sticky, as described in Sharding Rooms Across SFU Nodes β€” so a fresh node only relieves the pool at the rate new rooms are created. Scale out at 0.65, not 0.85, and accept that you are paying for headroom you will not use for several minutes.

Minimal Runnable Implementation

The control loop below runs on one leader instance, ticks every 30 s, and produces at most one pool change per cooldown window.

const TICK_MS = 30_000;        // one control tick every 30 s
const UP_AT = 0.65;            // pool mean load that triggers scale-out
const DOWN_AT = 0.30;          // pool mean load that permits scale-in
const UP_STREAK = 4;           // 4 of the last 5 ticks β‰ˆ 2 min of evidence
const DOWN_STREAK = 20;        // 10 min of quiet before shrinking
const COOLDOWN_MS = 300_000;   // 5 min between any two pool changes
const FLOOR = 3;               // never shrink past 3 nodes

// One node's load is the max of its real constraints. CPU is a guardrail:
// divided by 0.85 it only becomes the max once encryption work truly dominates.
function nodeLoad(n) {
  const egress = n.egressMbps / n.egressCeilingMbps;    // 1 s bytesSent deltas
  const streams = n.forwardedStreams / n.streamCeiling; // fixed per-stream cost
  const cpuGuard = n.cpu01 / 0.85;                      // 0.85 CPU => 1.0 load
  return Math.max(egress, streams, cpuGuard);
}

let upHits = [], downHits = [], lastChangeAt = 0;

async function tick(pool, store) {
  const live = pool.filter(n => n.healthy && !n.cordoned); // cordoned β‰  capacity
  const mean = live.reduce((s, n) => s + nodeLoad(n), 0) / live.length;

  upHits = [...upHits, mean >= UP_AT].slice(-5);          // rolling 5-tick window
  downHits = [...downHits, mean <= DOWN_AT].slice(-DOWN_STREAK);
  if (Date.now() - lastChangeAt < COOLDOWN_MS) return;    // one change per window

  if (upHits.filter(Boolean).length >= UP_STREAK) {
    await pool.launchNode();                              // absorbs NEW rooms only
    lastChangeAt = Date.now();
    upHits = [];                                          // reset both streaks
    return;
  }

  if (downHits.length === DOWN_STREAK && downHits.every(Boolean) &&
      live.length > FLOOR) {
    // Pick the node cheapest to empty, not the emptiest by CPU.
    const victim = [...live].sort((a, b) => a.forwardedStreams - b.forwardedStreams)[0];
    await store.set(`node:${victim.id}:cordoned`, '1', 'EX', 3600); // step 1: cordon
    victim.drainDeadline = Date.now() + 1_800_000;         // step 2: 30 min drain
    lastChangeAt = Date.now();
    downHits = [];
  }
}

setInterval(() => tick(pool, store).catch(console.error), TICK_MS);
What one 30-second control tick decides A tick reads pool metrics and computes a load score as the maximum of egress ratio, stream ratio and a CPU guardrail. If the score is at or above 0.65 on four of the last five ticks and the cooldown has elapsed, one node is launched. Otherwise, if the score is at or below 0.30 for twenty ticks and the pool is larger than three nodes, the lowest-stream node is cordoned, drained for up to thirty minutes and terminated. Every other path holds the pool unchanged. One control tick: three outcomes, at most one pool change tick β€” every 30 s load = max(egress, streams, cpu/0.85) mean over uncordoned healthy nodes load β‰₯ 0.65 on 4 of last 5 ticks? yes cooldown β‰₯ 300 s? yes launch +1 node takes new rooms only no β€” still cooling no load ≀ 0.30 for 20 ticks, pool > 3? yes cordon fewest-streams drain ≀ 30 min rooms end naturally terminate at 0 streams no hold β€” pool unchanged
Scale-out is one branch; everything else either holds or begins a removal that takes half an hour to finish.

Three details in that loop are easy to get wrong. The streak arrays are the hysteresis: without them a single 30 s tick containing one large room’s join burst launches a node you pay for and never fill. The loop launches exactly one node per decision rather than a proportional jump, because the pool’s mean is measured over nodes that are already saturated and cannot shed load β€” adding three nodes at once does not relieve the existing rooms three times faster, it just moves the next four minutes of new rooms onto cold hardware. And the loop must have exactly one writer; run it behind a leader lock in the same store that holds placement state, or two instances will each decide the pool is short and launch a node in the same tick.

Cordoning is the part that makes scale-in safe, and it is deliberately not the same thing as a failed health check. A cordoned node is healthy, keeps forwarding every packet for the rooms it already owns, and simply stops being a placement candidate β€” the flag lives in the same shared store the room-to-node mapping uses, so every signaling instance sees it on the next join without a deploy or a load-balancer change. Do not implement cordon by pulling the node out of service discovery: that would strand the participants already on it. Publish the cordon flag and the drain deadline as gauges alongside your load signals, following Exporting SFU Metrics to Prometheus and Grafana, or you will spend the drain window unable to tell a stuck node from a busy one.

Reproduction Steps & Debugging Log Patterns

  1. Write the policy the loop reads at boot, sizing the ceilings below the hardware rather than at it.
# sfu-autoscaler.ini β€” ceilings are per node, not per pool
[signals]
egress_ceiling_mbps = 700     ; 70% of a 1 Gbps NIC, leaves burst headroom
stream_ceiling      = 1200    ; forwarded outbound-rtp streams per node
cpu_guard           = 0.85    ; CPU only counts once it passes this
[policy]
scale_out_at = 0.65           ; pool mean load, sustained
scale_in_at  = 0.30
cooldown_s   = 300            ; no two pool changes inside 5 minutes
drain_max_s  = 1800           ; then stragglers are asked to reconnect
min_nodes    = 3
  1. Ramp synthetic rooms onto a 4-node pool until mean load passes 0.65 and confirm the streak logic waits out the noise instead of firing on a single spike.
// [scale] tick mean=0.61 egress=0.61 streams=0.22 cpu=0.28  hits=1/5
// [scale] tick mean=0.67 egress=0.67 streams=0.24 cpu=0.29  hits=2/5
// [scale] tick mean=0.66 egress=0.66 streams=0.24 cpu=0.29  hits=3/5
// [scale] tick mean=0.69 egress=0.69 streams=0.25 cpu=0.30  hits=4/5 -> LAUNCH
// [scale] node-e launching  pool=4->5  cooldown_until=+300s
  1. Read the same tick with only the CPU column. It never leaves the 0.28–0.30 band, which is the whole argument for the composite score.
  2. Force the opposite direction by ending rooms until mean load sits under 0.30 for 20 consecutive ticks, then watch the cordon land before anything terminates.
// [scale] mean=0.27 for 20 ticks, pool=5 > floor=3 -> CORDON node-e
// [place] node-e cordoned: skipped for room r-5512 (placed node-b)
// [drain] node-e streams=143 rooms=11 deadline=+1800s
// [drain] node-e streams=0 rooms=0 elapsed=944s -> TERMINATE
  1. Verify nothing was cut: across the drain window the node’s connectionState=failed count must stay at zero, and its egress must reach zero before termination, not at it. A single failed here means you terminated on the deadline while rooms were still live.

The one measurement people skip is warm-up, and it is the difference between a scale-out that helps and one that produces a wave of failed joins. Time the interval between the launch call and the new node’s first successful forwarded packet: on a typical image that is 20–45 s of boot, certificate load and port binding, and the node will answer a health check well before the end of it. Log the gap explicitly and refuse to count the node as capacity until it closes, otherwise the placement layer hands rooms to a node that cannot yet complete DTLS.

// [pool] node-e launch requested            t+0s
// [pool] node-e health=ok streams=0         t+11s  (NOT capacity yet)
// [pool] node-e first forwarded packet      t+34s  -> counted as capacity
// [place] room r-5610 -> node-e             t+36s  (first room lands)
Pool state across a scale-in Four panels. At T0 four nodes carry between 41 and 62 percent of their egress ceiling. At T1 load has fallen and node d is cordoned, so new rooms go to a, b and c while d keeps its existing traffic. At T2 node d has drained to 9 percent as its rooms end naturally and the other three have absorbed the new arrivals. At T3 node d is gone and the pool is three nodes at higher, healthier utilisation. Scale-in in four ticks β€” bars are % of each node's egress ceiling T0 Β· steady 4 nodes admitting a b c d T1 Β· cordoned d takes no new rooms a b c d T2 Β· draining its rooms end naturally a b c d T3 Β· terminated pool = 3, zero cuts a b c β€” Node d keeps forwarding for its own rooms from T0 to T2 β€” cordon removes it from placement, not from service
The bar for node d never jumps to zero; it decays, which is the only shape a safe scale-in can have.

Common Implementation Mistakes

FAQ

Should CPU be in the policy at all?

Yes, but only as a ceiling that rarely binds. Divide measured CPU by 0.85 before folding it into the max, so it contributes nothing until the node is genuinely spending its cores on SRTP and NACK retransmission β€” which happens on tiers with very many small audio streams, and almost never on video-heavy ones.

How long should the cooldown be?

Five minutes between pool changes, and it must cover node boot plus warm-up. An SFU node that has joined service discovery but has not yet loaded its certificates or opened its RTP ports will accept a room and fail it; count the node as capacity only after it reports its first successful forwarding tick.

Why not scale in faster when the pool is obviously idle?

Because idle is measured on bitrate, and a room can go from idle to full-rate the moment someone unmutes a camera. A 10-minute downward streak plus a 30-minute drain is cheap insurance against removing the node that was about to be needed; if you want the freeze and loss consequences of getting it wrong to be visible, wire the pool into the thresholds from Alerting on Freeze and Packet-Loss SLOs.

Related: this sits under Load Balancing & Scaling SFUs; read it with SFU vs MCU Cost & Quality Trade-offs for the per-node cost model that sets your ceilings.