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.

Advertisement

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.

The ABR control loop: measure, estimate, decide, fetch, and watch the in-flight downloadSegment downloaderbytes, timing per requestThroughput estimatorEWMA / harmonic meanBuffer modelseconds of media queuedDecision functionthroughput rule | BBA | BOLA | MPC | learnedRule combinermin / priorityNext segment requestrendition index kAbandonment checkabort if it will stallManifest ladderbitrates, sizes, durationPlayback: buffer drains at 1 s per second; a stall happens when it reaches zerosamplesestimatelevelindexprogress
The decision function sits between measurement and the next request; a separate abandonment check watches the request after it is made.

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: abrBandWidthFactor 0.95 for staying at a level and abrBandWidthUpFactor 0.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 0

Throughput 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.

Advertisement

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, wait

Read 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

SymptomUsual causeFix
Stuck at low quality on low-latency streamsThroughput measured over chunked transfer includes idle time between chunksMeasure only while bytes are flowing, or use a low-latency rule such as L2A or LoL+
Oscillation between two rungsEstimate hovers near a bitrate boundaryAsymmetric factors (hls.js uses 0.95 to stay, 0.7 to climb), switch history penalty
Stall right after an upswitchUpswitch decided on one fast cached sampleHarmonic mean, minimum segment count before upswitch, abandonment
Long low-quality startPure buffer rule with an empty bufferThroughput rule during startup, sensible starting estimate
Dropped frames at high rungsDevice cannot decode the top renditionDropped-frames rule, cap the ladder by device capability

Trade-offs

ApproachStrengthWeakness
Throughput ruleSimple, fast start, works with short buffersLags drops, fooled by caches and chunked delivery
BBAFew stalls, stable, no estimatorSlow start, needs a long buffer
BOLAProvable guarantees, bounded bufferSame buffer dependence; parameters are not intuitive
RobustMPCPlans ahead, explicit QoE weightsCompute cost, still needs a predictor
LearnedCan exploit patterns in tracesDistribution 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

  1. Write down your QoE objective: weights for stalls, switches, quality and startup, agreed with product.
  2. Log per-segment telemetry from real players: chosen index, buffer, measured throughput, download time, stalls.
  3. Build the trace-driven simulator above and reproduce your current player's behaviour first.
  4. Compare your current rule with a hybrid (throughput at low buffer, BOLA or BBA when healthy) on the same traces.
  5. Verify abandonment works by throttling a test device mid-segment.
  6. Ship changes behind an experiment and compare rebuffering ratio and quality distributions, not means.
Key takeaway: ABR is a control problem under uncertainty: throughput rules react to measurements, buffer rules react to the cushion those measurements produced, BOLA gives the buffer approach a principled scoring function, and MPC plans over a predicted future. Real players combine a primary rule with guards, and abandonment is the guard that prevents most stalls. Choose by your buffer length and latency target, and decide with traces and experiments, not intuition.