How Adaptive Bitrate Streaming Works in HLS: BANDWIDTH, ABR Algorithms, and Why /convert Doesn't Need Them
July 12, 2026 · 6 min read
How HLS streaming works mentions in passing that a player "picks a variant based on the viewer's bandwidth and screen size, and can switch to a different one mid-playback if the network gets slower." That switching has a name — adaptive bitrate streaming (ABR) — and it's doing more work, and more often, than a one-line mention suggests. It also, somewhat counterintuitively, isn't something every tool on this site needs at all.
The attribute that makes ABR possible: BANDWIDTH
Every variant in a master playlist is announced with an #EXT-X-STREAM-INF line carrying a BANDWIDTHattribute — the encoder or packager's own estimate of the peak bits-per-second that variant needs, in bytes terms roughly what a player would have to sustain over the network to stay ahead of playback:
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1920x1080#EXT-X-STREAM-INF:BANDWIDTH=2800000,RESOLUTION=1280x720#EXT-X-STREAM-INF:BANDWIDTH=800000,RESOLUTION=854x480
This set of variants — the same content encoded at several resolution/bitrate pairs — is usually called a bitrate ladder. Nothing about the playlist forces a player to pick any particular rung on it; BANDWIDTH is just a number the player can compare against its own read of the network. What a player does with that number, and how often it reconsiders, is where the actual ABR logic lives — and the spec deliberately leaves that logic unspecified. RFC 8216 tells an encoder how to describe variants; it says nothing about how a client should choose between them.
Picking the first variant vs. switching mid-stream
ABR is really two separate decisions with different constraints:
- Startup selection.Before a single segment has downloaded, a player has no real measurement of the viewer's throughput yet. Most implementations start conservatively — often the lowest or a middle rung of the ladder — get a segment or two onto the screen as fast as possible, and only climb once actual download speed is known. Starting on the top rung risks a long initial stall on a slow connection, which is a worse first impression than a soft picture for a few seconds.
- Mid-playback switching.Once segments are flowing, a player has two independent signals to react to: how fast recent segments actually downloaded (throughput-based ABR) and how much buffered video is sitting ahead of the playhead (buffer-based ABR). A throughput-based approach switches down as soon as download speed drops below the current rung's bitrate; a buffer-based one tolerates a slow segment as long as there's still enough buffer to absorb it, and only downgrades once the buffer itself starts shrinking. Production players — hls.js included — blend both signals rather than relying on either alone, because throughput alone reacts to network noise that a healthy buffer would have absorbed without the viewer ever noticing.
What this looks like in this site's own player
/play hands ABR entirely to hls.js rather than reimplementing any of it. VideoPlayer.tsx constructs Hls with no bitrate config beyond the bridge-first loader and lowLatencyMode, listens for Hls.Events.MANIFEST_PARSED to build a quality list from data.levels, and defaults currentLevel to -1— hls.js's own code for "auto, let the library decide." The quality dropdown in the player's controls bar exposes that same -1as an explicit "Auto" option alongside every fixed resolution hls.js reported, and picking a fixed one just sets hls.currentLeveldirectly, overriding ABR until the stream reloads. Every actual switching decision — when to step down, how fast to climb back up, how much buffer to require before trying a higher rung — is hls.js's internal logic, not anything this site's own code implements.
Why /convert doesn't do any of this
/converttakes a genuinely different approach, and it's worth being explicit about why. parseMaster() in lib/m3u8-parser.ts reads every #EXT-X-STREAM-INF variant, sorts them by resolution height and then bandwidth, and returns just the single highest one — variants[0]after that sort, full stop. It ignores that same line's CODECS attribute entirely too, for reasons covered in H.264 vs. H.265 vs. VP9 vs. AV1 in HLS. There's no level list, no switching, no BANDWIDTH-vs-measured-throughput comparison anywhere in Converter.tsx's stageMedia(), which takes whatever URL parseMaster() handed back and fetches every one of its segments in order.
That's not a missing feature — it's the right call for what the converter is doing. ABR exists to keep playbacksmooth while a video streams in real time on a connection of unknown and possibly changing speed. A conversion job isn't playing anything; it's fetching every segment of one chosen quality level to disk, as fast as the network and ffmpeg.wasm allow, with no playhead that can stall. A slow segment just makes the progress bar move slower for a moment — there's no viewer staring at a frozen frame while it catches up, so there's nothing for a bitrate switch to protect. Picking the best available quality once, up front, and downloading exactly that is a simpler and more predictable choice than adapting a target that was never moving in the first place.
The trade-off: no fallback if the top rung is genuinely too slow
The honest downside of that design is that parseMaster()has no path back down. If a source's highest-bandwidth variant is throttled, geo-shaped, or simply hosted somewhere slow, the converter has already committed to it before the first segment request goes out — there's no mechanism that notices a string of slow fetches and retries against the 720p or 480p rung instead. A real ABR implementation would treat that exact situation as the signal to step down a rung; this converter just keeps fetching the one variant it picked, at whatever speed that turns out to be. In practice this rarely matters, since a conversion job isn't time-sensitive the way live playback is — slow just means a longer wait, not a broken result — but it's a genuine limitation worth naming rather than glossing over, the same way the audio-track selection gap is.
FAQ
Does a higher BANDWIDTH number always mean better quality? In practice, yes for variants from the same source ladder — encoders allocate more bits to higher-resolution or higher-fidelity renditions. But BANDWIDTH is a bitrate estimate, not a resolution; parseMaster()'s sort deliberately checks RESOLUTION height first and only falls back to BANDWIDTH to break ties between variants that report the same resolution.
If I pick a fixed quality in the /play dropdown, does the video still adapt if my connection changes? No — setting hls.currentLevel to anything other than -1pins hls.js to that exact rung until you either pick "Auto" again or the player reloads the stream. That's intentional: it's the same manual override every ABR player offers for a viewer who'd rather trade smoothness for a guaranteed resolution.
Why doesn't the converter just add ABR-style fallback too? It could, in principle — retry a variant one rung down after enough failed or slow segment fetches — but that's meaningfully more logic than parseMaster()currently has any of, and conflates two different problems: choosing a quality level (which it already does, once) and recovering from a bad fetch (which is a retry/error-handling concern, not an adaptive-bitrate one). Today it does neither for the converter's single fetch pass.