Stop hls.js from flapping between quality levels on cellular (with abrSwitchInterval)

작성자

카테고리:

← 피드로
DEV Community · Mason K · 2026-08-04 개발(SW)

Mason K

TL;DR

ABR “flapping” is when your player hops between quality levels every few seconds on a jittery network, and each hop is a visible lurch. We’ll detect it from LEVEL_SWITCHED events, then fix it in layers: widen the bandwidth-estimator memory, make upswitches earn their place, and cap the switch rate with abrSwitchInterval (new in hls.js 1.7). Config + a detection snippet you can paste in today.

📦 Code: github.com/USER/hlsjs-abr-tuning, replace before publishing

The bug nobody reports correctly

Users don’t file “my ABR is flapping.” They say the video “kept changing” or “couldn’t decide.” What’s happening: on cellular, throughput is spiky, and the player’s bandwidth estimator treats every spike as the new truth. One fast segment and it jumps to 1080p, one slow segment and it drops to 240p, over and over. Low rebuffer ratio, good startup time, and still a miserable watch.

Counterintuitively, feeding the player fresher bandwidth data makes this worse, because fresher data is noisier. The fix is a player with a longer memory and slower reflexes. Let’s build that.

1. First, detect the flap 📊

Don’t tune by vibes. Count level switches per minute of playback. Every switch fires Hls.Events.LEVEL_SWITCHED.

// abr-monitor.js, hls.js 1.7.x, node 20+ tooling / any modern browser
import Hls from "hls.js";

export function attachFlapMonitor(hls) {
  const switches = [];
  hls.on(Hls.Events.LEVEL_SWITCHED, (_evt, data) => {
    const now = performance.now();
    switches.push({ t: now, level: data.level });
    // keep a 60s sliding window
    while (switches.length && now - switches[0].t > 60_000) switches.shift();

    const perMin = switches.length;
    const reversals = countReversals(switches);
    if (perMin >= 6) {
      console.warn(`[abr] flapping: ${perMin} switches/min, ${reversals} reversals`);
    }
  });
}

// a "reversal" = up then down (or down then up), the signature of flapping
function countReversals(s) {
  let r = 0;
  for (let i = 2; i < s.length; i++) {
    const a = Math.sign(s[i - 1].level - s[i - 2].level);
    const b = Math.sign(s[i].level - s[i - 1].level);
    if (a !== 0 && b !== 0 && a !== b) r++;
  }
  return r;
}

Enter fullscreen mode Exit fullscreen mode

# what a flapping session logs on 4G:
[abr] flapping: 11 switches/min, 7 reversals
[abr] flapping: 9 switches/min, 6 reversals

Enter fullscreen mode Exit fullscreen mode

A healthy cellular session still switches. A flapping one switches many times a minute and keeps reversing direction. That reversal count is your target metric.

2. Layer one: give the estimator a longer memory

hls.js estimates bandwidth with an exponentially weighted moving average that has a fast and a slow half-life, and uses the more conservative of the two. Widen both half-lives so short spikes stop moving the estimate.

const hls = new Hls({
  // defaults are ~3.0 / ~9.0 (VoD) and ~3.0 / ~9.0 (live).
  // widen them so the estimate reflects the last several seconds, not the last one.
  abrEwmaFastVoD: 4.0,
  abrEwmaSlowVoD: 15.0,
  abrEwmaFastLive: 4.0,
  abrEwmaSlowLive: 15.0,
});

Enter fullscreen mode Exit fullscreen mode

Bigger numbers mean a slower, calmer estimate. On a jittery connection this alone removes most of the drama.

3. Layer two: make upswitches earn it

Going down to avoid a stall is urgent. Going up is optional. Don’t treat them symmetrically. Lower the up-switch factor so the player demands sustained headroom before reaching for a higher rendition.

const hls = new Hls({
  abrEwmaFastVoD: 4.0,
  abrEwmaSlowVoD: 15.0,
  abrBandWidthFactor: 0.95,     // margin applied to down-switch decisions
  abrBandWidthUpFactor: 0.5,    // default ~0.7; lower = more conservative upswitching
});

Enter fullscreen mode Exit fullscreen mode

💡 Tip: abrBandWidthUpFactor is the highest-leverage single knob for “it keeps jumping to a level it can’t hold.” Drop it before you touch anything else.

4. Layer three: rate-limit the switches with abrSwitchInterval 🚦

The older knobs shape the estimate and hope fewer switches fall out. hls.js 1.7 added a knob that limits the switches themselves: abrSwitchInterval sets a minimum time between ABR level changes.

const hls = new Hls({
  abrEwmaFastVoD: 4.0,
  abrEwmaSlowVoD: 15.0,
  abrBandWidthUpFactor: 0.5,
  // NEW in 1.7: minimum seconds between automatic ABR switches.
  // The estimator still decides WHERE to go; this decides HOW OFTEN it may.
  abrSwitchInterval: 3.0,
});

Enter fullscreen mode Exit fullscreen mode

The 1.7 line also brought CMCD v2, I-frame playlist support, and faster startup via parallel init-segment loading, but abrSwitchInterval is the one that directly kills flapping. Pair it with the EWMA tuning rather than relying on it alone: the estimator picks the target, the interval caps the churn.

⚠️ Don’t set the interval so high that a genuine, sustained bandwidth drop can’t trigger a timely downswitch. 2 to 4 seconds is a sane starting range. Watch your rebuffer ratio when you raise it.

5. Layer four (last resort): cap levels on known-bad networks

If you have a signal that the viewer is on a constrained connection, cap the ceiling so the top renditions are never even considered. A stable 480p beats a flapping 1080p on a bus.

// use the Network Information API where available as a hint
function applyNetworkProfile(hls) {
  const c = navigator.connection;
  if (c && (c.effectiveType === "2g" || c.effectiveType === "3g" || c.saveData)) {
    // cap to the highest level whose height <= 480
    const cap = hls.levels.reduce((best, lvl, i) => (lvl.height <= 480 ? i : best), 0);
    hls.autoLevelCapping = cap;
  }
}
hls.on(Hls.Events.MANIFEST_PARSED, () => applyNetworkProfile(hls));

Enter fullscreen mode Exit fullscreen mode

6. Putting it together as a profile

Wrap the knobs in a small config so you can A/B it against defaults and watch the flap monitor.

// hls-config.js
export const antiFlapConfig = {
  abrEwmaFastVoD: 4.0,
  abrEwmaSlowVoD: 15.0,
  abrEwmaFastLive: 4.0,
  abrEwmaSlowLive: 15.0,
  abrBandWidthFactor: 0.95,
  abrBandWidthUpFactor: 0.5,
  abrSwitchInterval: 3.0,
};

// usage
import Hls from "hls.js";
import { antiFlapConfig } from "./hls-config.js";
import { attachFlapMonitor } from "./abr-monitor.js";

const hls = new Hls(antiFlapConfig);
attachFlapMonitor(hls);
hls.loadSource("https://example.com/master.m3u8");
hls.attachMedia(video);

Enter fullscreen mode Exit fullscreen mode

Now flip between {} and antiFlapConfig on the same throttled 4G session and compare the monitor output. You want switches-per-minute and reversals to fall while rebuffer ratio stays flat. If rebuffering climbs, you went too conservative: raise abrBandWidthUpFactor back toward 0.7 or lower abrSwitchInterval.

What’s next

Once the flap is under control, feed the same LEVEL_SWITCHED and rebuffer data into CMCD v2 so your CDN logs can explain what the client saw, and correlate flap rate against effectiveType buckets to build per-network profiles. The whole fix is one idea: an adaptive player fails in the direction of overconfidence, so slow its reflexes down and make it earn every upswitch.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다