▶M3U8 SPACE

Why Your HLS Stream Keeps Buffering

Most HLS buffering is not a bandwidth problem. It comes from a ladder whose bitrates do not match real throughput, segments that are too long or too short relative to #EXT-X-TARGETDURATION , a buffer target that is too small for a variable

Most HLS buffering is not a bandwidth problem. It comes from a ladder whose bitrates do not match real throughput, segments that are too long or too short relative to #EXT-X-TARGETDURATION, a buffer target that is too small for a variable network, or variants that are not aligned on the same GOP boundaries. Start by measuring how much forward buffer the player actually holds, then tune the ladder and the client config instead of simply lowering quality.

How it works

An HLS player downloads segments one at a time into a forward buffer. Adaptive bitrate logic estimates available bandwidth from recent segment downloads and picks the highest variant that fits. If the estimate is optimistic, the player requests a rendition it cannot sustain, the buffer drains to zero, and the video stalls while it refills. It then drops to a lower variant, which often produces a visible quality swing.

The playlist defines the rhythm. #EXT-X-TARGETDURATION states the maximum segment duration, and a player typically waits until it has a few segments buffered before it starts. Six-second segments with a ten-second target mean a stall costs at least one full segment to recover. Two-second segments recover faster but add HTTP request overhead and more playlist refreshes. #EXT-X-ENDLIST marks VOD; without it the player follows a live edge and will buffer if it falls too far behind or too close to the edge.

You can watch the buffer directly:

hls.on(Hls.Events.FRAG_BUFFERED, () => {
  const v = video;
  const buffered = v.buffered.length
    ? (v.buffered.end(v.buffered.length - 1) - v.currentTime).toFixed(1)
    : '0';
  console.log('forward buffer:', buffered, 's | level:', hls.currentLevel);
});
video.addEventListener('waiting', () => console.warn('stall at', video.currentTime));

Step-by-step: tune the ladder and the client

1. Build a ladder with sensible spacing. Jumps of 2x between renditions cause oscillation. A typical VOD ladder for a 1080p source is 2400, 1600, 1100, 750, and 450 kbps with matching resolutions. Every variant must share the same GOP length and the same segment boundaries so switching does not produce a decode gap.

# One rendition: fixed 6 s segments, closed GOP at 2 s, aligned keyframes
ffmpeg -i source.mp4 \
  -c:v libx264 -preset veryfast -b:v 1600k -maxrate 1740k -bufsize 3200k \
  -g 48 -keyint_min 48 -sc_threshold 0 -pix_fmt yuv420p \
  -c:a aac -b:a 128k -ac 2 \
  -hls_time 6 -hls_playlist_type vod \
  -hls_segment_filename "v1600/seg_%05d.ts" v1600/index.m3u8

Repeat for each rung with different -b:v and resolution, then write a master playlist with #EXT-X-STREAM-INF:BANDWIDTH=...,RESOLUTION=... pointing at each variant. Declare the correct BANDWIDTH (including audio): an understated value makes the ABR logic over-select that rendition.

2. Give the client enough buffer.

const hls = new Hls({
  maxBufferLength: 30,          // seconds of forward buffer to target
  maxMaxBufferLength: 60,       // hard ceiling under stable conditions
  backBufferLength: 30,         // keep some played video for seeking back
  startLevel: -1,               // let ABR choose, or 0 for a safe low start
  abrEwmaDefaultEstimate: 800000,
  capLevelToPlayerSize: true,   // do not fetch 1080p for a 480p element
  lowLatencyMode: false         // enable only for LL-HLS playlists
});

A low abrEwmaDefaultEstimate makes the player start conservatively and ramp up, which prevents the very first stall on a cold connection. capLevelToPlayerSize avoids wasting bandwidth on pixels the viewer cannot see.

3. Check the delivery path. A CDN that revalidates every segment, or an origin that does not support Range, adds latency to each request. Playlists should be cached briefly (a few seconds for VOD, near zero for live), while segments can be cached for a long time because their URLs are immutable. Verify with curl -w "%{time_starttransfer}" that the first segment arrives promptly.

Common mistakes and parameters

SymptomMost likely causeCheck first
Stall right at startABR estimate too optimisticabrEwmaDefaultEstimate, startLevel
Stalls every few minutesLadder rung above real throughputBANDWIDTH values, level switches
Quality oscillatesRungs too far apartAdd intermediate bitrates
Switch causes a freezeVariants not GOP aligned-g, -sc_threshold 0
Live stream drifts behindPlaying too close to the edgeliveSyncDurationCount
Buffering only on one devicePlayer size vs level capscapLevelToPlayerSize
SettingDefault tendencyPractical guidance
-hls_time2 s in many examples4-6 s for VOD, 2 s for low latency
#EXT-X-TARGETDURATIONMust be >= longest segmentRound up, keep constant across variants
maxBufferLength30 s20-30 s VOD, lower for live
backBufferLengthInfinite in some builds30-90 s to bound memory
Playlist refreshVOD none, live periodicHalf the segment duration is a common target

Troubleshooting

  • The buffer graph repeatedly hits zero at the same bitrate: that rung is unsustainable; lower its bitrate or remove it from the ladder.
  • FRAG_LOAD_ERROR appears before each stall: this is a delivery or CORS failure, not ABR; check segment status codes and headers first.
  • Stalls only after a quality switch: variants have different keyframe intervals; re-encode with a fixed -g and disabled scene-cut keyframes.
  • The player starts at the top level and drops immediately: set startLevel: 0 or lower abrEwmaDefaultEstimate for a gentle ramp.
  • Audio continues while video freezes: the container or codec is the problem, not throughput; inspect with ffprobe and try a different rendition.
  • Everything is smooth locally but buffers for remote viewers: measure from another network, then look at CDN cache hit ratio and origin response time.

Related reading