Choosing ABR Rungs for Mobile Bandwidth
Most published ladders are lists of resolutions with bitrates attached, and on a cellular audience they fail in a specific way: three of the five rungs sit above what the connection ever sustains, so the phone spends its whole session on the bottom two and you pay storage and cache dilution for renditions nobody selects. The constraint this page works from is different — a rung is worth encoding only if a measurable share of your sessions can actually hold it, and that share is a function of your own throughput distribution, not of a resolution list. It is the mobile-specific complement to the ladder theory in adaptive bitrate streaming with HLS and DASH: same control loop, different input data.
Prerequisite checklist
Read the distribution, not the resolution list
Every rung has a required throughput: the rung’s total BANDWIDTH divided by the player’s safety factor. hls.js will not upshift into a rung until its estimate exceeds BANDWIDTH / 0.7, so a 2 600 kbps rung needs a 3.7 Mbps estimate to ever be selected. Plot those required throughputs against the cumulative distribution of your measured samples and the ladder designs itself: the point where a rung’s marker crosses the curve is the percentage of sessions that will never see it.
Three properties of that curve drive every decision below. It is heavily left-skewed, so the mean (7.1 Mbps in this sample) sits above the 65th percentile and is worthless as a design input. It is close to log-normal, which is why geometric rung spacing beats arithmetic spacing. And its lower tail is long: the difference between p5 and p1 is a factor of three, so a floor placed by eye is almost always too high.
The planner
One script converts the percentiles into a complete rung set. It is deliberately small — the value is in the constants, each of which is a decision you should be able to defend.
// plan-ladder.mjs — derive an ABR ladder from measured cellular throughput.
// Run: node plan-ladder.mjs (Node 18+, no dependencies)
/* 1. FIELD INPUT — sustained per-segment throughput in Mbps, from your RUM
beacon. Percentiles only; a mean is dominated by the fast tail. */
const PCT = { p5: 0.42, p10: 0.71, p25: 1.60, p50: 4.20, p75: 9.80, p90: 18.5 };
/* 2. SAFETY FACTOR — hls.js refuses to upshift into a rung until its
estimate exceeds BANDWIDTH / abrBandWidthUpFactor. Design against
usable throughput, not raw throughput, or the top rungs are dead
weight. shaka-player: abr.bandwidthUpgradeTarget. dash.js: 0.9. */
const SAFETY = 0.70;
/* 3. FLOOR AND CEILING
FLOOR_PCT — the percentile you promise to serve without rebuffering.
p5 means 95% of sessions hold the bottom rung immediately.
CEILING_PCT — the percentile the top rung targets. Above p75 you are
encoding for a tail that also has Wi-Fi available.
DEVICE_CEILING — hard cap in kbps from decode budget and element size;
the lower of the two limits always wins. */
const FLOOR_PCT = 'p5';
const CEILING_PCT = 'p75';
const DEVICE_CEILING = 4500;
/* 4. SPACING — target ratio between adjacent rungs. Below 1.5 the rungs are
perceptually identical and dilute your CDN cache; above 2.2 a downshift
is a visible cliff. The script solves for the exact ratio that lands
the final rung on the ceiling. */
const TARGET_SPACING = 1.8;
/* 5. AUDIO — a fixed cost subtracted from each rung's budget. HE-AAC v1 at
64 kbps on the floor rung keeps its video allowance above 200 kbps. */
const audioFor = (i, n) => (i === 0 ? 64 : i >= n - 2 ? 128 : 96);
/* 6. RESOLUTION THRESHOLDS — the video bitrate at which each resolution
stops looking worse than the one below it. Raise ~40% for sport,
lower ~25% for talking-head or screencast content. */
const RES = [
{ w: 426, h: 240, min: 0 }, { w: 640, h: 360, min: 380 },
{ w: 854, h: 480, min: 700 }, { w: 960, h: 540, min: 1300 },
{ w: 1280, h: 720, min: 2200 }, { w: 1920, h: 1080, min: 4000 },
];
const round10 = k => Math.round(k / 10) * 10;
const pickRes = v => RES.filter(r => v >= r.min).pop();
// Log-linear interpolation across the measured percentiles: what share of
// sessions fall BELOW a given throughput.
const pctAt = mbps => {
const pts = Object.entries(PCT)
.map(([k, v]) => [v, Number(k.slice(1))])
.sort((a, b) => a[0] - b[0]);
if (mbps <= pts[0][0]) return pts[0][1] * (mbps / pts[0][0]);
for (let i = 1; i < pts.length; i++) {
if (mbps <= pts[i][0]) {
const f = (Math.log10(mbps) - Math.log10(pts[i - 1][0])) /
(Math.log10(pts[i][0]) - Math.log10(pts[i - 1][0]));
return pts[i - 1][1] + f * (pts[i][1] - pts[i - 1][1]);
}
}
return 95;
};
const floorKbps = round10(PCT[FLOOR_PCT] * 1000 * SAFETY);
const ceilingKbps = Math.min(round10(PCT[CEILING_PCT] * 1000 * SAFETY),
DEVICE_CEILING);
// Number of steps that lands closest to TARGET_SPACING, then the exact ratio.
const steps = Math.max(1, Math.round(
Math.log(ceilingKbps / floorKbps) / Math.log(TARGET_SPACING)));
const ratio = (ceilingKbps / floorKbps) ** (1 / steps);
const ladder = Array.from({ length: steps + 1 }, (_, i) => {
const total = round10(floorKbps * ratio ** i); // manifest BANDWIDTH, kbps
const audio = audioFor(i, steps + 1);
const video = total - audio; // what ffmpeg gets as -b:v
const r = pickRes(video);
const need = total / SAFETY / 1000; // Mbps needed to select it
return { rung: i, kbps: total, video, audio,
res: `${r.w}x${r.h}`, needMbps: +need.toFixed(2),
reach: `${Math.round(100 - pctAt(need))}%` };
});
console.table(ladder);
console.log(`ratio ${ratio.toFixed(2)}x over ${steps + 1} rungs`);
// Player configuration that matches the ladder you just designed.
console.log(JSON.stringify({
startLevel: 1, // never start on the floor
abrEwmaDefaultEstimate: Math.round(PCT.p25 * 1e6), // cold-start guess, bps
abrBandWidthUpFactor: SAFETY, // must equal the design value
abrBandWidthFactor: 0.95, // downshift threshold
capLevelToPlayerSize: true, // enforce the element cap
maxBufferLength: 20, // mobile memory is scarce
backBufferLength: 15,
}, null, 2));
Running it against the sample distribution produces this:
| Rung | BANDWIDTH |
Video kbps | Audio | Resolution | Throughput needed | Sessions that can hold it |
|---|---|---|---|---|---|---|
| 0 | 290 000 | 226 | 64 (HE-AAC) | 426×240 | 0.41 Mbps | 95% |
| 1 | 500 000 | 404 | 96 | 640×360 | 0.71 Mbps | 90% |
| 2 | 870 000 | 774 | 96 | 854×480 | 1.24 Mbps | 80% |
| 3 | 1 500 000 | 1 404 | 96 | 960×540 | 2.14 Mbps | 67% |
| 4 | 2 600 000 | 2 472 | 128 | 1280×720 | 3.71 Mbps | 53% |
| 5 | 4 500 000 | 4 372 | 128 | 1920×1080 | 6.43 Mbps | 37% |
The 540p rung is the one that never appears in a copied ladder and does the most work here: it sits exactly where the distribution is steepest, so it absorbs the sessions that would otherwise oscillate between 480p and 720p. The floor is higher than the conventional 200 kbps 234p rung because the measured p5 supports it — inherit a floor from a broadband ladder and you either waste a rung or serve avoidable mush. Feed the video column straight into the encode described in building an HLS bitrate ladder with FFmpeg.
Prune the ceiling before you encode it
The planner’s DEVICE_CEILING is a single number standing in for two independent limits, and both need checking against your own audience before you accept it. A rung wider than the element will ever render is bandwidth spent on pixels that get downscaled away; a rung the device cannot decode in hardware drops frames and drains battery even when the network delivers it perfectly.
The arithmetic on the first limit is unforgiving. A phone player inline in an article is typically 390 CSS px wide at DPR 3, which is 1 170 device pixels — 720p covers it, 1080p does not help. Fullscreen on the same device is 844×390 CSS, still under 1 280 device px in the constrained dimension for most handsets. That is why capLevelToPlayerSize belongs on for mobile and why the 1080p rung earns its place from tablets and desktop traffic, not from phones.
Tradeoff: capping by element size is unambiguously right for an inline embed and mildly wrong for a player the user will fullscreen. When capLevelToPlayerSize is on, the transition to fullscreen produces a visible quality jump one or two segments later, because the cap only lifts after the resize event. Either accept the jump or listen for fullscreenchange and pre-emptively raise autoLevelCapping before the element resizes.
How many rungs the player can actually use
The planner returned six rungs because a 1.8 ratio is what fits between the floor and the ceiling. The obvious “improvement” is to fill the same range with nine rungs at 1.35 spacing, on the theory that finer granularity lets the player track the network more closely. On cellular it does the opposite, and the reason is in the estimator rather than in the ladder.
hls.js drives selection from an exponentially weighted moving average of recent segment throughputs — abrEwmaFastVoD (3 s) and abrEwmaSlowVoD (9 s), with the lower of the two estimates winning. On a cellular link, adjacent 4-second segments routinely differ by ±40% purely from scheduler and handover noise, so the fast estimate wanders across a band roughly 1.8× wide even when capacity is constant. Space the rungs 1.35× apart and that noise band spans two rung boundaries: the player crosses a threshold on jitter, not on a real change in capacity.
Each crossing costs more than a resolution change. hls.js runs an abandon rule on every in-flight fragment (_abandonRulesCheck): if the projected download time exceeds the buffer it has left, it aborts the request and re-issues the same segment at a lower level, discarding whatever had already arrived. On a 1.35× ladder the rung below is only 26% smaller, so the retry is frequently abandoned as well and the player burns two partial downloads to get one segment. On a 1.8× ladder the retry lands 44% cheaper and almost always completes.
Too few rungs is the mirror failure, and it is quantifiable. With geometric spacing of ratio r, a session whose usable throughput sits log-uniformly between two rungs receives, on average, a fraction (1 − 1/r) / ln r of what it could have carried:
| Ratio between rungs | Usable throughput delivered | Bandwidth left unused | Switches per 120 s on the trace below |
|---|---|---|---|
| 1.35× | 86.4% | 13.6% | 14 |
| 1.60× | 79.7% | 20.3% | 8 |
| 1.80× | 75.6% | 24.4% | 4 |
| 2.20× | 68.7% | 31.3% | 3 |
| 2.50× | 65.5% | 34.5% | 2 |
So spacing buys delivered bitrate with switch frequency, and 1.8 is the knee: tightening to 1.35 recovers eleven points of throughput utilisation and roughly triples the switch count.
Tradeoff: if you genuinely need finer granularity — a live sports feed where the top of the ladder carries the value — buy it with a slower estimator rather than with more rungs. Raising abrEwmaSlowVoD from 9 to 15 seconds widens the window a single fast segment has to survive before it can trigger an upshift, which suppresses jitter switching without giving up delivered bitrate. More rungs cannot do that: they make the estimator’s noise more consequential, not less.
Fixed ladder or per-title convex hull
Everything above produces a fixed ladder — one set of (resolution, bitrate) pairs applied to every title. Per-title encoding replaces that with a measurement. Encode a grid of candidates, typically six resolutions × six quantiser points, score each one with VMAF after upscaling to a common display resolution, then take the upper convex hull of the resulting (bitrate, quality) cloud. The hull answers a question a fixed ladder can only guess at: at this bitrate, which resolution actually looks best? Rungs are then sampled along the hull at roughly six VMAF points apart, which is about one just-noticeable difference.
For a mobile audience the choice of VMAF model matters more than the hull algorithm. The default model is calibrated for a 1080p display at three screen heights; the phone model (enable_transform on vmaf_v0.6.1, exposed as --phone-model) is calibrated for a handset held at arm’s length and scores the same encode 5–9 points higher at the low end. Under the phone model the 540p and 720p curves converge above about 2 Mbps — the same conclusion the element-size cap reached, arrived at from pixels rather than from CSS.
The cost is where per-title encoding usually stalls. A full grid is 36 test encodes plus 36 VMAF runs per title; run it over the whole title and you have multiplied your encode bill by twenty. Run it over three 10-second excerpts chosen for motion complexity instead and the analysis costs roughly the same as one extra rendition, which is affordable in a build pipeline. Budget for it the same way you would budget any other transcode step — the per-codec timings in AV1 vs VP9 encode time on AWS Lambda are the right order of magnitude, and Sharp and FFmpeg media build pipelines covers running it as a cached build step rather than by hand.
Warning: the headline figure for per-title encoding — 15–20% bitrate saving at equal VMAF — is a catalogue-wide average, and most of it accrues on the top rungs. Those are precisely the rungs a cellular audience spends the least time on. Weight the saving by your measured rung occupancy before you commit to the pipeline: on the distribution at the top of this page the field-visible saving is closer to 6%, and the throughput-derived floor is worth more than the hull. Fix the rung placement first; add per-title analysis afterwards, if at all.
Verification
1. Replay the sample against the candidate ladder
Before encoding anything, run every throughput sample you have through the same selection rule the player uses and count where sessions land. This costs seconds and catches a badly-placed floor immediately.
# throughput.csv holds one Mbps sample per line, one line per segment fetch.
# For each sample, pick the highest rung whose BANDWIDTH fits under
# throughput * 0.7 — the same rule hls.js applies — and tally the result.
awk -F, '
BEGIN { split("290 500 870 1500 2600 4500", R, " ") }
{ usable = $1 * 1000 * 0.70; sel = -1
for (i = 1; i <= 6; i++) if (R[i] <= usable) sel = i - 1
if (sel < 0) below++; else count[sel]++
total++ }
END { printf "below floor: %.1f%%\n", 100 * below / total
for (i = 0; i < 6; i++)
printf "rung %d: %.1f%%\n", i, 100 * count[i] / total }
' throughput.csv
Two numbers decide whether the ladder is acceptable: below floor must be under 5% (those sessions rebuffer no matter what you do), and rung 0 must be under 10% of playback time. If rung 0 takes 30% you have a broadband ladder on a cellular audience.
2. Compare predicted occupancy against the old ladder
3. Bench the transitions at the design percentiles
Chrome DevTools accepts custom throttling profiles. Create three — 0.71 Mbps / 300 ms RTT (p10), 1.6 Mbps / 180 ms (p25) and 4.2 Mbps / 90 ms (p50) — then log which rung the player settles on at each. The rung it converges to should match the planner’s reach column within one step.
// Log rung selection and the estimate that drove it, once per segment.
hls.on(Hls.Events.LEVEL_SWITCHED, (_, { level }) => {
const l = hls.levels[level];
console.log(`level ${level} — ${l.width}x${l.height} @ ${Math.round(l.bitrate / 1000)}k`,
`estimate ${(hls.bandwidthEstimate / 1e6).toFixed(2)} Mbps`,
`buffer ${hls.media.buffered.length
? (hls.media.buffered.end(0) - hls.media.currentTime).toFixed(1)
: 0}s`);
});
Warning: DevTools throttling is applied per-request with a fixed rate, so it produces a far smoother throughput series than a real cellular link. Use it to confirm rung reachability, never to estimate switch frequency — that number is only meaningful from field data.
4. Confirm in the field
Ship a beacon sampling hls.currentLevel every five seconds along with hls.bandwidthEstimate, and compare the resulting occupancy histogram to the prediction from step 1. A field distribution shifted one rung below the prediction almost always means the estimator is being throttled by TCP slow start on small segments rather than that the ladder is wrong. Track it alongside the rest of your delivery telemetry — the collection patterns in monitoring and regression for media delivery apply unchanged to segment beacons.
Common mistakes
1. Placing the floor near the median
The floor exists for the worst sessions you intend to serve, not the typical one. A floor at the median means half your sessions cannot play the bottom rung and rebuffer from the first segment.
// WRONG: floor derived from the middle of the distribution.
const floorKbps = round10(PCT.p50 * 1000 * SAFETY); // 2940 kbps
// CORRECT: floor at the low percentile you promise to serve.
const floorKbps = round10(PCT.p5 * 1000 * SAFETY); // 290 kbps
2. Copying a broadband or television ladder unchanged
Apple’s authoring recommendations and most vendor presets target living-room and desktop delivery, where the top three rungs are reachable. Dropped onto a cellular audience, those rungs collect single-digit percentages of playback time while the floor collects a third of it — the exact shape of the grey bars in the chart above. Move the rungs; do not add more of them.
3. Designing rungs to match throughput exactly
A rung whose BANDWIDTH equals the measured throughput will never be selected, because the player applies its safety factor first.
// WRONG: this rung needs 2.8 Mbps of real throughput to be chosen, and no
// session at exactly 2.8 Mbps will ever be allowed to pick it.
{ bandwidth: 2_800_000, target: '2.8 Mbps sessions' }
// CORRECT: divide by the player's upshift factor when reasoning about reach.
{ bandwidth: 2_600_000, needs: 2_600_000 / 0.70 } // 3.71 Mbps
4. Adding rungs instead of moving them
Every extra rung splits CDN traffic across another set of objects. On a low-traffic origin an eight-rung ladder measurably increases rebuffering, because more segments are served cold from origin rather than from a warm edge — the same effect described in best practices for setting max-age on CDN media assets. Six rungs covering the range that matters beats nine rungs half of which are never selected.
5. Ignoring Save-Data and the rendered element size
// WRONG: the ladder is uncapped, so a 390 px-wide inline player fetches the
// 1080p rung whenever the network allows, and a data-saver user pays for it.
const hls = new Hls();
// CORRECT: cap by element size always, and by explicit user intent when the
// client advertises it. autoLevelCapping is an index into hls.levels.
const hls = new Hls({ capLevelToPlayerSize: true });
hls.on(Hls.Events.MANIFEST_PARSED, () => {
const saveData = navigator.connection?.saveData === true;
if (saveData) {
// Index 2 is the 854x480 rung in the ladder above.
hls.autoLevelCapping = 2;
}
});
Note that navigator.connection.saveData is a legitimate use of the Network Information API — it reports a stated user preference. Using navigator.connection.downlink to pick a rung is not: it is Chromium-only, rounded, capped at 10 Mbps, and deliberately coarse. The player’s own segment-level estimate is both more accurate and available everywhere.
Related
- Adaptive Bitrate Streaming with HLS and DASH — the parent reference for the ABR control loop, manifests and segment duration
- Building an HLS Bitrate Ladder with FFmpeg — encoding the rung set this page produces, with every flag annotated
- DASH vs HLS for Browser Playback Support — which players will consume the rung set, and the safety factors each one applies to it
- Hardware AV1 Decode Support by Device Class — the device data behind the decode ceiling in the pruning tree