
By Frank Volkmar, Technical Editor at IPTVZ
"It keeps buffering" is the most common complaint about live streaming, and it's also the least specific. The cause could be the source, the CDN, the home network, the device's decoder or the player app. Before changing settings at random, it's worth measuring. Most live IPTV streams today are delivered over HLS (HTTP Live Streaming), so all you need for a first diagnosis is plain HTTP tooling.
This post walks through four checks I use: read the playlist, inspect the codecs, time the segments and run a decode test. At the end there's a short Python script that combines the timing checks.
All examples use public test streams, so you can run them as-is. Only test streams you're allowed to access.
How HLS delivers a live channel
An HLS stream is a set of text playlists plus short media files:
- A master playlist lists the available quality levels (variants), each with a bandwidth, resolution and codec string.
- Each variant has a media playlist listing the segments, usually 2 to 10 seconds of video each.
- The player downloads segments one after another. For live channels it reloads the media playlist to find new segments.
That structure gives you a simple rule for buffering: the player must download each segment faster than it plays. If a 6-second segment takes 8 seconds to arrive, the buffer drains and playback stalls, no matter which app you use.
Check 1: read the master playlist
Start by looking at what the server offers:
curl -s "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
The output includes lines like this:
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=6221600,CODECS="mp4a.40.2,avc1.640028",RESOLUTION=1920x1080,NAME="1080"
url_8/193039199_mp4_h264_aac_fhd_7.m3u8
Three things to note:
- BANDWIDTH is the peak bitrate in bits per second, so 6221600 is about 6.2 Mbps. Your connection needs comfortable headroom above that for the variant you want.
- CODECS tells you what the device must decode.
avc1is H.264,hvc1orhev1is H.265/HEVC, andmp4a.40.2is AAC audio. An older TV that can't decode HEVC will struggle with HEVC-only streams, which looks like buffering but is really a decoder problem. - If there is only one variant, the player can't step down to a lower quality when the network dips. That makes a single-bitrate stream much less forgiving.
Check 2: confirm the codecs with ffprobe
The playlist states what the stream should contain. ffprobe (part of FFmpeg) reads the actual media:
ffprobe -v error \
-show_entries stream=codec_type,codec_name,profile,width,height,avg_frame_rate \
-of json "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
For a master playlist, ffprobe reports each variant as a separate program. A typical video entry looks like this:
{ "codec_name": "h264", "profile": "High", "codec_type": "video",
"width": 1280, "height": 720, "avg_frame_rate": "60/1" }
Frame rate matters more than people expect. A 1080p stream at 60 frames per second needs noticeably more decoding power than the same resolution at 30, and budget streaming sticks are where that shows first. If the video stutters while the network numbers look fine, compare the codec, profile and frame rate with what the device officially supports.
Check 3: time a segment download
Next, measure the network directly. Fetch the media playlist, pick a recent segment and time it with curl's -w variables:
V="https://test-streams.mux.dev/x36xhzz/url_0/193039199_mp4_h264_aac_hd_7.m3u8"
SEG=$(curl -s "$V" | grep -v '^#' | tail -1)
curl -s -o /dev/null \
-w 'http=%{http_code} ttfb=%{time_starttransfer}s total=%{time_total}s size=%{size_download}B\n' \
"$(dirname "$V")/$SEG"
Compare total with the segment's duration from the #EXTINF line just above it in the playlist:
- Total well under the duration (for example, 1.5 seconds for a 6-second segment): the network is fine for this variant.
- Total close to the duration: playback will work until the connection dips, then stall. That's the classic "buffers every few minutes" pattern.
- Total longer than the duration: this variant can't play smoothly on this connection, whatever the app.
The ttfb value (time to first byte) is also useful. A high time to first byte with a fast transfer after it points at a slow server or a distant CDN edge, not your Wi-Fi.
Run the same test over Ethernet and over Wi-Fi from the TV's location. If wired is fine and wireless isn't, you've found the problem without touching the player.
Check 4: run a decode test without a screen
FFmpeg can pull a stream and decode it without displaying anything:
ffmpeg -hide_banner -v error -i "STREAM_URL" -t 60 -f null -
-f null - throws the decoded frames away, and -v error prints only real problems. A clean run prints nothing. Errors about corrupt packets or missing segments during a 60-second run point at the source or the delivery path rather than the device. If this test is clean on a laptop but the TV still stutters, look at the device and the app.
If you prefer timings, add -benchmark and drop -v error to see how long decoding took compared with the 60 seconds of content.
A small Python script to combine the checks
Doing this by hand for every variant gets tedious, so here is a standard-library script that lists the variants and times the newest segments of the highest one:
"""hls_check.py - list the variants of an HLS stream and test whether the
connection keeps up with one of them.
Usage: python hls_check.py MASTER_URL [segments]
"""
import re
import sys
import time
import urllib.request
from urllib.parse import urljoin
UA = {"User-Agent": "Mozilla/5.0 (hls_check)"}
def fetch(url):
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=20) as r:
return r.read()
def attrs(line):
# Split KEY=VALUE pairs, keeping quoted values (CODECS="a,b") intact.
return dict(re.findall(r'([A-Z0-9-]+)=("[^"]*"|[^,]*)', line.split(":", 1)[1]))
def variants(master_url):
lines = fetch(master_url).decode("utf-8", "replace").splitlines()
out = []
for i, line in enumerate(lines):
if line.startswith("#EXT-X-STREAM-INF"):
a = attrs(line)
uri = next(l for l in lines[i + 1:] if l and not l.startswith("#"))
out.append({
"bandwidth": int(a.get("BANDWIDTH", 0)),
"resolution": a.get("RESOLUTION", "?"),
"codecs": a.get("CODECS", "").strip('"'),
"url": urljoin(master_url, uri),
})
return sorted(out, key=lambda v: v["bandwidth"])
def segments(media_url, count):
lines = fetch(media_url).decode("utf-8", "replace").splitlines()
segs, duration = [], None
for line in lines:
if line.startswith("#EXTINF:"):
duration = float(line[8:].split(",")[0])
elif line and not line.startswith("#") and duration is not None:
segs.append((duration, urljoin(media_url, line)))
duration = None
# For live playlists the newest segments are at the end.
return segs[-count:]
def main():
master = sys.argv[1]
count = int(sys.argv[2]) if len(sys.argv) > 2 else 3
vs = variants(master) or [{"bandwidth": 0, "resolution": "?", "codecs": "", "url": master}]
for v in vs:
print(f'{v["bandwidth"] / 1e6:6.2f} Mbps {v["resolution"]:>9} {v["codecs"]}')
top = vs[-1]
print(f'\nTesting the highest variant ({top["resolution"]}):')
for duration, url in segments(top["url"], count):
start = time.monotonic()
size = len(fetch(url))
took = time.monotonic() - start
ratio = duration / took if took else float("inf")
verdict = "ok" if ratio >= 1.5 else ("tight" if ratio >= 1 else "too slow")
print(f" {duration:5.1f}s segment, {size / 1e6:5.2f} MB in {took:5.2f}s -> {ratio:4.1f}x real time ({verdict})")
if __name__ == "__main__":
main()
Run it against Apple's public example stream:
python hls_check.py https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8 2
On a slow connection, the output looks like this (variant list trimmed):
5.05 Mbps 1920x1080 avc1.64002a,mp4a.40.2
8.18 Mbps 1920x1080 avc1.64002a,mp4a.40.2
Testing the highest variant (1920x1080):
6.0s segment, 5.98 MB in 9.42s -> 0.6x real time (too slow)
6.0s segment, 5.98 MB in 10.57s -> 0.6x real time (too slow)
The "x real time" figure is the segment's duration divided by its download time. Anything below 1.0 can't play continuously, and I treat 1.5 as the minimum for a comfortable live buffer. In the run above, a player would need to drop to a lower variant to play smoothly, which is exactly what adaptive streaming is designed to do when more than one variant exists.
A few notes on the script:
- It handles relative and absolute segment URLs with
urljoin, which covers most playlists. - It doesn't handle encrypted segments' keys, byte-range playlists or separate audio renditions. It only times downloads, so for this purpose that's fine.
- Some servers check the User-Agent. If you get HTTP 403 errors, use the same User-Agent your player sends.
Reading the results together
| What you see | Likely cause | What to try |
|---|---|---|
| Segments download slower than real time | Network or CDN | Wired connection, a lower quality, a different time of day |
| High time to first byte, fast transfer | Server or CDN edge | Not fixable at home; report it with the numbers |
| Fast downloads, stutter on the TV only | Decoder or app | Check codec and frame rate support, update or change the player |
| Decode test shows errors on every device | Source stream | Report it to the provider with the timestamps |
Numbers like these also make support conversations much shorter. "A 6-second segment takes 9 seconds on Wi-Fi and 2 seconds on Ethernet" gets a better answer than "it buffers."
For the non-command-line side of the same problem, such as router placement, app settings and device-specific fixes, IPTVZ keeps an IPTV troubleshooting guide with step-by-step fixes.
Frank Volkmar is the technical editor at IPTVZ, where he writes setup and troubleshooting guides for streaming devices.
About the author
- IPTVZ is a US live TV streaming service. We write practical guides on streaming devices, player apps, EPG data and fixing buffering. More guides at https://www.iptvz.net/
Comments
Join the discussionNo comments yet. Be the first to comment.
