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_SWITCHEDevents, then fix it in layers: widen the bandwidth-estimator memory, make upswitches earn their place, and cap the switch rate withabrSwitchInterval(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;
}
# what a flapping session logs on 4G:
[abr] flapping: 11 switches/min, 7 reversals
[abr] flapping: 9 switches/min, 6 reversals
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,
});
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
});
💡 Tip:
abrBandWidthUpFactoris 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,
});
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));
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);
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.
Top comments (0)