Every few seconds a video player answers one question: which rendition of the next segment should I fetch? The answer decides whether the viewer sees a sharp picture, a blurry one, or a spinner. The adaptive bitrate overview explains the encoding ladder and the player loop. This article is about the decision function itself: the algorithm families that real players and research systems use, what each one measures, the code that implements it, and how they fail.
By the end you should be able to read a player's ABR configuration and know what it does, implement a throughput rule, a buffer rule, BOLA and a small MPC, and evaluate any of them against network traces before shipping.
The decision, stated precisely
The manifest offers a ladder of renditions with bitrates R1 < R2 < ... < Rn, cut into segments of L seconds. Before requesting segment k the player knows its buffer level B (seconds of media queued), a history of download throughputs, and the rendition it used last. Downloading segment k at rendition r takes size(k, r) / C seconds, where C is the throughput the network actually delivers, which the player does not know yet.
The buffer evolves as Bnext = max(B − size/C, 0) + L. If the download takes longer than B, playback stalls for the difference. The research literature, notably the MPC work of Yin et al. (SIGCOMM 2015), writes the goal as a quality-of-experience sum: total quality minus a penalty for stall time minus a penalty for quality switches, with startup delay often added. Every algorithm below is a different approximation of maximising that sum while C is uncertain.
Throughput is noisy, so predictions will sometimes be badly wrong, and the buffer is the only cushion: a 30 second buffer forgives a guess that a 3 second live buffer will not.
Family 1: throughput-based rules
The oldest approach estimates throughput from recent segment downloads and picks the highest rendition below a safety fraction of the estimate. The design work is all in the estimator.
- Exponentially weighted moving average. hls.js keeps two EWMAs with different half-lives and uses the lower of the two, so it reacts quickly to drops and slowly to recoveries. Its documented defaults are half-lives of 3 and 9 seconds (
abrEwmaFastVoD,abrEwmaSlowVoD, with live equivalents), a starting estimate of 500 kbps, and two scale factors:abrBandWidthFactor0.95 for staying at a level andabrBandWidthUpFactor0.7 for switching up. - Harmonic mean of the last N samples. The harmonic mean is dominated by the slow samples, so one fast cached segment cannot inflate it. MPC uses it as its predictor.
- Safety factor. Multiply the estimate by something below 1 before comparing with bitrates; dash.js calls its version
bandwidthSafetyFactor. Lower values trade quality for fewer stalls.
class ThroughputRule:
def __init__(self, ladder_kbps, n=5, safety=0.9):
self.ladder, self.n, self.safety = ladder_kbps, n, safety
self.samples = [] # kbps per finished segment
def on_download(self, bits, seconds):
self.samples = (self.samples + [bits / seconds / 1000])[-self.n:]
def estimate(self):
if not self.samples:
return None
return len(self.samples) / sum(1 / s for s in self.samples) # harmonic mean
def choose(self):
est = self.estimate()
if est is None:
return 0 # start low, or use a configured start estimate
budget = est * self.safety
ok = [i for i, r in enumerate(self.ladder) if r <= budget]
return ok[-1] if ok else 0Throughput rules fail in two predictable ways. They lag: by the time the average reflects a drop, the player has already committed to a large segment. And they mismeasure: with low-latency chunked delivery the segment arrives at the encoder's pace, so measured throughput is close to the current bitrate and the rule can never justify switching up; with a warm CDN cache, a few segments arrive implausibly fast and the rule overshoots.
Family 2: buffer-based control (BBA)
Huang et al. (SIGCOMM 2014, work done with Netflix) argued that during steady state the buffer level already encodes the information the throughput estimate is trying to capture. Their buffer-based approach maps buffer level directly to a rate: below a reservoir always pick the lowest rendition, above reservoir plus cushion pick the highest, and interpolate in between.
def bba_choose(ladder_kbps, buffer_s, reservoir_s=5.0, cushion_s=20.0):
if buffer_s <= reservoir_s:
return 0
if buffer_s >= reservoir_s + cushion_s:
return len(ladder_kbps) - 1
frac = (buffer_s - reservoir_s) / cushion_s
target = ladder_kbps[0] + frac * (ladder_kbps[-1] - ladder_kbps[0])
return max(i for i, r in enumerate(ladder_kbps) if r <= target)The appeal is stability: no noisy estimator, and the rule backs off automatically as the buffer drains. The cost is at the edges. At startup the buffer is empty, so pure BBA plays the lowest rendition for a long time (the paper uses throughput during startup), and a short live buffer leaves no room for a meaningful reservoir and cushion.
Family 3: BOLA
BOLA (Spiteri, Urgaonkar and Sitaraman, INFOCOM 2016) is also buffer-based but derived from Lyapunov optimisation, which gives it a provable bound relative to the best offline policy under its model. For each rendition m it computes a score and picks the maximum. With Q the buffer measured in segments, Sm the segment size and vm = ln(Sm/S1) a log utility, the BOLA-BASIC score is (V(vm + γp) − Q) / Sm.
import math
def bola_choose(sizes_bits, buffer_s, seg_s, V, gp):
"""BOLA-BASIC. sizes_bits ascending; the buffer is measured in segments."""
Q = buffer_s / seg_s
v = [math.log(s / sizes_bits[0]) for s in sizes_bits]
scores = [(V * (v[m] + gp) - Q) / sizes_bits[m] for m in range(len(sizes_bits))]
best = max(range(len(scores)), key=scores.__getitem__)
return None if scores[best] <= 0 else best # None: buffer is full, waitRead the score as quality per bit, discounted by how full the buffer is. When Q is small the γp term dominates and dividing by Sm favours small segments. As Q grows, the higher utility of large segments wins. When Q exceeds V(vmax + γp) every score is negative and BOLA tells the player to pause downloading, which is a feature: it bounds the buffer without a separate cap. V and γp are chosen from the minimum and maximum buffer you want; dash.js derives them from its own buffer settings, so read its BolaRule source if you need exact parity.
dash.js ships BOLA alongside a ThroughputRule, and its documentation states that when both rules are enabled the player switches dynamically between them based on buffer level: throughput while the buffer is low, BOLA once it is healthy. That hybrid exists precisely because BOLA, like BBA, is weak when the buffer is near empty.
Family 4: model predictive control
MPC (Yin et al., SIGCOMM 2015) plans ahead. It predicts throughput for the next few segments, enumerates every rendition sequence over that horizon, simulates the buffer for each, scores each plan with the QoE formula, and executes only the first step of the best plan before re-planning. RobustMPC divides the prediction by (1 + the largest recent relative prediction error), so a player that has recently been fooled becomes cautious.
import itertools
def mpc_choose(ladder_kbps, seg_s, buffer_s, last, pred_kbps,
horizon=5, stall_pen=4.3, switch_pen=1.0):
best_q, best_first = float("-inf"), 0
for plan in itertools.product(range(len(ladder_kbps)), repeat=horizon):
b, prev, qoe = buffer_s, last, 0.0
for idx in plan:
dl = ladder_kbps[idx] * seg_s / pred_kbps # seconds to download
stall = max(dl - b, 0.0)
b = max(b - dl, 0.0) + seg_s
qoe += ladder_kbps[idx] / 1000 - stall_pen * stall \
- switch_pen * abs(ladder_kbps[idx] - ladder_kbps[prev]) / 1000
prev = idx
if qoe > best_q:
best_q, best_first = qoe, plan[0]
return best_first
def robust_prediction(harmonic_kbps, recent_rel_errors):
return harmonic_kbps / (1 + max(recent_rel_errors, default=0.0))With six renditions and a horizon of five, that loop scores 65 = 7,776 plans per decision: fine in a simulator, expensive on a weak TV, which is why the paper's FastMPC precomputes decisions into a table. The penalty weights are product policy, not physics.
Family 5: learned policies
Pensieve (Mao, Netravali and Alizadeh, SIGCOMM 2017) trained a neural policy with reinforcement learning in a simulator driven by recorded throughput traces and reported QoE gains over hand-built rules on those trace sets. A learned policy is only as good as the match between its training traces and your users' networks; outside that distribution it can do worse than a simple rule and is harder to debug. Adopt one only with a faithful simulator, representative traces, and guard rules that can override it.
How real players compose rules
Production players do not run one algorithm; they run a primary rule plus guards. dash.js organises ABR as a collection of rules that each cast a switch request which are aggregated into the final decision; its rule set includes ThroughputRule, BolaRule, InsufficientBufferRule, SwitchHistoryRule, DroppedFramesRule, AbandonRequestRule, and the low-latency L2A and LoL+ rules, configured under streaming.abr.rules. Which of the secondary rules are enabled by default has changed between releases, so check the settings page for the version you ship.
The guard that matters most is abandonment. Once a request is in flight, the player can see bytes arriving. If the projected finish time exceeds the buffer, the right move is to abort and refetch at a lower rendition rather than stall.
def should_abandon(bytes_done, bytes_total, elapsed_s, buffer_s, lower_size_bytes):
if elapsed_s < 0.5 or bytes_done == 0:
return False # not enough evidence yet
rate = bytes_done / elapsed_s
remaining_s = (bytes_total - bytes_done) / rate
refetch_s = lower_size_bytes / rate
# abort only if finishing would stall AND the cheaper segment would not
return remaining_s > buffer_s and refetch_s < buffer_s
Worked example: the same moment, four algorithms
Ladder 300, 750, 1500, 3000 and 6000 kbps; 4 second segments; buffer 12 seconds; the last five throughput samples were 8.0, 8.0, 7.5, 2.2 and 2.0 Mbps, because the viewer just walked away from the router.
- Harmonic-mean throughput rule. 5 / (1/8 + 1/8 + 1/7.5 + 1/2.2 + 1/2) = 3.74 Mbps; times 0.9 = 3.36 Mbps; it picks 3000 kbps. At the real 2.0 Mbps a 12 Mbit segment takes 6 seconds, so the buffer falls to 12 − 6 + 4 = 10 seconds. Survivable, but draining.
- Fast estimator (mean of the last two samples). 2.1 Mbps times 0.9 = 1.89 Mbps; it picks 1500 kbps and the buffer grows.
- BBA with a 5 second reservoir and 20 second cushion. (12 − 5) / 20 = 0.35; target 300 + 0.35 × 5700 = 2295 kbps; it picks 1500 kbps without looking at throughput at all.
- RobustMPC. The last prediction was about 3.7 Mbps against an observed 2.2, a relative error of roughly 0.68. The robust prediction is 3.74 / 1.68 = 2.2 Mbps, and planning at that rate favours 1500 kbps.
Had the harmonic rule picked 6000 kbps, the 24 Mbit download at 2.0 Mbps would take 12 seconds and empty the buffer exactly; any further dip would stall. That is the abandonment rule's job.
Evaluating an ABR change before users do
Never tune ABR on intuition. Build a trace-driven simulator: replay recorded throughput traces (public research has used FCC broadband and Norwegian 3G/HSDPA traces; your own player telemetry is better), drive each algorithm through the same traces and the same content, and compare distributions, not averages.
def simulate(choose, trace_kbps, ladder_kbps, seg_s=4.0, n_segments=150):
t, buf, last, stalls, switches, quality = 0.0, 0.0, 0, 0.0, 0, []
for k in range(n_segments):
idx = choose(buf, last)
bw = trace_kbps[int(t) % len(trace_kbps)] # throughput at this moment
dl = ladder_kbps[idx] * seg_s / bw
stalls += max(dl - buf, 0.0) if k > 0 else 0.0 # first segment is startup delay
buf = max(buf - dl, 0.0) + seg_s
t += dl
switches += idx != last
last = idx
quality.append(ladder_kbps[idx])
return dict(stall_s=stalls, switches=switches, mean_kbps=sum(quality) / len(quality))Report rebuffering ratio, startup time, mean quality (preferably a perceptual metric such as VMAF per rendition rather than raw bitrate), switches per minute, and the tail of each across traces. Then confirm with an experiment on real sessions; the simulator ignores CDN latency, TCP slow start and decode limits.
Failure modes
| Symptom | Usual cause | Fix |
|---|---|---|
| Stuck at low quality on low-latency streams | Throughput measured over chunked transfer includes idle time between chunks | Measure only while bytes are flowing, or use a low-latency rule such as L2A or LoL+ |
| Oscillation between two rungs | Estimate hovers near a bitrate boundary | Asymmetric factors (hls.js uses 0.95 to stay, 0.7 to climb), switch history penalty |
| Stall right after an upswitch | Upswitch decided on one fast cached sample | Harmonic mean, minimum segment count before upswitch, abandonment |
| Long low-quality start | Pure buffer rule with an empty buffer | Throughput rule during startup, sensible starting estimate |
| Dropped frames at high rungs | Device cannot decode the top rendition | Dropped-frames rule, cap the ladder by device capability |
Trade-offs
| Approach | Strength | Weakness |
|---|---|---|
| Throughput rule | Simple, fast start, works with short buffers | Lags drops, fooled by caches and chunked delivery |
| BBA | Few stalls, stable, no estimator | Slow start, needs a long buffer |
| BOLA | Provable guarantees, bounded buffer | Same buffer dependence; parameters are not intuitive |
| RobustMPC | Plans ahead, explicit QoE weights | Compute cost, still needs a predictor |
| Learned | Can exploit patterns in traces | Distribution shift, hard to debug |
For context on delivery, see HLS versus DASH, video CDN architecture and the end-to-end streaming architecture.
What to do next
- Write down your QoE objective: weights for stalls, switches, quality and startup, agreed with product.
- Log per-segment telemetry from real players: chosen index, buffer, measured throughput, download time, stalls.
- Build the trace-driven simulator above and reproduce your current player's behaviour first.
- Compare your current rule with a hybrid (throughput at low buffer, BOLA or BBA when healthy) on the same traces.
- Verify abandonment works by throttling a test device mid-segment.
- Ship changes behind an experiment and compare rebuffering ratio and quality distributions, not means.