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.

Cellular Throughput Distribution with Rung Requirements A cumulative distribution curve rising from left to right on a logarithmic throughput axis from 0.2 to 40 megabits per second. Six dashed vertical markers labelled R0 to R5 mark the throughput each ladder rung requires after the player safety factor is applied. The lowest rung sits near the fifth percentile and the highest near the sixty-third percentile. Share of 4 s segment fetches completing at or below a given throughput — cellular sessions only R0 R1 R2 R3 R4 R5 100% 75% 50% 25% 0% 0.2 0.5 1 2 5 10 20 40 Mbps Sample: 480 000 segment fetches from cellular sessions over four weeks. Substitute your own — this shape varies enormously by market. Markers are BANDWIDTH ÷ 0.7, the throughput hls.js needs to observe before it will upshift into that rung.

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.

Pruning the Top of the Ladder A decision tree evaluating a candidate rung above 720p. The first question asks whether the player element ever renders at least 1280 device pixels wide; if not the rung is dropped. The second asks whether the median device decodes the codec in hardware; if not the rung is kept only in the H.264 rendition group. The third asks whether Save-Data is set or the connection is metered; if so the session is capped at 480p, otherwise the rung is kept. Candidate rung above 720p Does the element ever render at 1280+ device pixels wide? no yes Drop the rung entirely capLevelToPlayerSize: true Hardware decode on the median device in your analytics? no yes Keep it in the H.264 group only, not AV1 Save-Data header set, or connection reported metered? yes no Cap the session at 480p hls.autoLevelCapping = 2 Keep the rung it earns its storage Run this per rung from the top down and stop at the first "keep" — everything below it is already justified by the distribution.

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.

Switch Frequency at 1.35x and 1.8x Rung Spacing Two stacked panels share one 120 second time axis and one identical cellular throughput trace that oscillates between 1.2 and 3.4 megabits per second. In the upper panel the ladder rungs are spaced 1.35 times apart, so six closely packed dashed threshold lines sit inside the range the trace wanders through and fourteen switch markers appear beneath it. In the lower panel the rungs are 1.8 times apart, only four dashed thresholds fall in range, and just four switch markers appear. One 120 s cellular trace, two ladders. Dashed lines are BANDWIDTH ÷ 0.7 — the level the estimate must cross to move. Nine rungs, 1.35× spacing 14 rung switches Half of these are abandoned fragment loads: the partial download is discarded and re-requested one rung lower. Six rungs, 1.8× spacing 4 rung switches 0 s 20 40 60 80 100 120 The trace is identical in both panels. Only the threshold density changed.

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.

Which Resolution Owns Each Bitrate Band A rising curve plotted against a logarithmic bitrate axis from 0.2 to 4 megabits per second and a VMAF axis from 50 to 100. Dashed vertical dividers split the curve into four bands labelled 360p, 480p, 540p and 720p, marking the bitrate at which each resolution takes over as the best choice. Five dots on the curve mark the planner rungs. The curve is steep below 1 megabit per second and flattens above 2. Upper convex hull of a six-resolution encode grid, scored with the phone VMAF model VMAF 360p 480p 540p 720p 50 60 70 80 90 100 0.2 0.3 0.5 1 2 4 Mbps Dots are five of the six planner rungs mapped onto the hull; the 4 500 kbps rung sits beyond the right edge. Under the desktop VMAF model the 540p band nearly disappears — which is why a desktop ladder skips 540p and a mobile ladder should not.

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

Rung Occupancy: Broadband Ladder vs Mobile-Tuned Ladder Six pairs of vertical bars showing the share of playback time spent at 240p, 360p, 480p, 540p, 720p and 1080p. The broadband ladder concentrates 58 percent of time at 240p and 360p and has no 540p rung. The mobile-tuned ladder shifts the mass upward, spending 21 percent at 540p and 25 percent at 720p. Share of playback time per delivered resolution, same 480 000-session sample 0% 10% 20% 30% none 240p 360p 480p 540p 720p 1080p broadband ladder (200/600/1200/2400/4800) mobile-tuned ladder from the planner Rebuffer ratio 1.9% → 0.8%; mean delivered video bitrate 1.24 → 2.31 Mbps; bottom-rung time 31% → 7%.

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.