HLS Stays Blurry After the Network Recovers: An ABR Diagnosis

Diagnose HLS quality that remains low after bandwidth returns by separating ABR estimates, manual locks, player caps, playlist metadata, delivery, and display evidence.

An HLS player drops to a low rendition during congestion. The connection recovers, a speed test looks fast again, but the picture remains soft. That does not prove the adaptive bitrate algorithm is stuck. The player may still be gathering evidence, loading media already selected earlier, respecting a manual or viewport cap, avoiding a rendition that recently failed, or playing a high-resolution encode that is visually poor.

Diagnose the selected rendition, the next requested rendition, the bandwidth evidence, and the image actually decoded as separate facts. Test only streams you own or are authorized to inspect. Remove signed URLs, cookies, tokens, viewer identifiers, IP addresses, and private hostnames before sharing logs.

Verification method — September 25, 2026: We ran a deterministic Node.js 24.12.0 fixture with four synthetic levels declared at 0.5, 1.4, 3.0, and 6.0 Mbps. A deliberately simple estimator used estimate = 0.3 × sample + 0.7 × previous and selected a level at or below 80% of that estimate. After 0.6 and 0.8 Mbps samples, it selected the 0.5 Mbps level. With repeated 8 Mbps samples, it selected 1.4 Mbps after the first new sample, 3.0 Mbps after the second, and 6.0 Mbps after the eighth. A level cap held the result at 3.0 Mbps, a manual lock held it at 0.5 Mbps, and a 2.4 MB four-second synthetic segment measured 4.8 Mbps against a declared 3.0 Mbps. This verifies only our fixture's arithmetic. It is not the hls.js ABR algorithm and does not test a browser, HLS playback, CDN, shaped network, decoder, display quality, or physical device. The fixture is stored at scripts/test-hls-abr-recovery-fixture.cjs.

Prove which quality is actually playing

“Blurry” is a visual symptom, not a level number. Record at least four different values:

EvidenceQuestion it answersCommon mistake
Current rendered levelWhich rendition supplies the frames playing now?Treating the next request as already visible
Next or loading levelWhich rendition is the player requesting next?Calling a scheduled upgrade a completed switch
Intrinsic video sizeWhat dimensions does the decoded video report?Using the CSS player size as source resolution
Display size and device pixel ratioHow many screen pixels need to be filled?Calling a correct 720p rendition “low” on a dense 4K display
Player bandwidth estimateWhat capacity does the player currently believe is available?Replacing it with an unrelated speed-test result
Buffered mediaHow much previously selected content remains ahead?Expecting a quality change before old buffer plays out

For browser video, video.videoWidth and video.videoHeight report the intrinsic dimensions of the current resource when media is available. In a JavaScript/MSE player, also record its current, loading, and next level. Native HLS paths do not expose the same hls.js properties, so use the player's documented metrics, media properties, and a permitted network capture instead of inventing parity.

Snapshot every limit that can block an upgrade

hls.js exposes enough state to separate automatic selection from a cap or manual choice. Capture it before the slowdown, during the low-quality period, and after the network recovers.

function rangeEnd(ranges) {
  return ranges.length ? ranges.end(ranges.length - 1) : null;
}

function abrSnapshot(hls, video) {
  const bufferedEnd = rangeEnd(video.buffered);
  return {
    playerVersion: Hls.version,
    autoLevelEnabled: hls.autoLevelEnabled,
    manualLevel: hls.manualLevel,
    currentLevel: hls.currentLevel,
    loadLevel: hls.loadLevel,
    nextAutoLevel: hls.nextAutoLevel,
    maxAutoLevel: hls.maxAutoLevel,
    autoLevelCapping: hls.autoLevelCapping,
    bandwidthEstimate: Number.isFinite(hls.bandwidthEstimate)
      ? hls.bandwidthEstimate
      : null,
    intrinsic: { width: video.videoWidth, height: video.videoHeight },
    display: {
      width: video.getBoundingClientRect().width,
      height: video.getBoundingClientRect().height,
      devicePixelRatio: window.devicePixelRatio,
    },
    bufferAhead: bufferedEnd === null ? null : bufferedEnd - video.currentTime,
  };
}

The current hls.js API defines autoLevelEnabled, manualLevel, autoLevelCapping, maxAutoLevel, bandwidthEstimate, currentLevel, loadLevel, and nextAutoLevel as distinct observations. A low currentLevel while nextAutoLevel is higher can simply mean that the upgrade has not reached the screen yet. A high bandwidth estimate with maxAutoLevel below the desired rendition points to a cap, not slow recovery.

Do not publish full player state if it includes private URLs. Store level IDs, declared bandwidth, dimensions, sanitized status codes, and monotonic timestamps.

Follow fragment evidence after the recovery

A speed test opens separate connections to a separate service under different cache, routing, server, and request-size conditions. It does not measure how quickly this player receives the next authorized media object. Use the requests made by the playback session.

For hls.js, align FRAG_LOADING, FRAG_LOADED, FRAG_BUFFERED, LEVEL_SWITCHING, LEVEL_SWITCHED, and ERROR events on one monotonic timeline. Record the level, media duration, bytes, request start, first byte, completion, and whether the request came from cache. Use the fields actually present in your player version rather than copying a logger written for another release.

Observation after bandwidth returnsLikely boundaryNext check
No new fragment request for a whileExisting forward buffer is still being consumedRecord buffer ahead and wait for the next selection opportunity
New samples remain slowCDN path, server response, packet loss, or request overheadSeparate time to first byte from transfer time
Estimate rises but maximum level stays lowViewport, FPS, application, device, or business-rule capRecord every cap and the event that set it
Automatic selection is disabledManual quality choice or stale application stateRestore Auto through the product's supported control
High level is requested but errorsRendition playlist, segment, codec, key, or authorizationInspect the first failed high-level request
High level is loaded but picture remains softEncoding, scaling, decoder, or mistaken level mappingConfirm intrinsic dimensions and inspect approved source media

If media requests stall or arrive too slowly, use the broader HLS buffering diagnosis. If the problem begins only after returning from a hidden tab or locked device, use the background and screen-lock recovery guide.

Expect estimates to have memory

An adaptive player should avoid switching upward on a single optimistic sample if doing so is likely to cause another stall. Estimators can retain evidence from recent slow downloads, apply a safety factor, and consider buffer starvation. The exact formula and defaults depend on the player and version.

Our synthetic estimator needed several equal 8 Mbps samples before it selected a rendition declared at 6 Mbps. That is an illustration of estimator memory and a safety margin, not a prediction for hls.js or any production player. Current hls.js documentation exposes its bandwidth estimate and configuration, while its API guide states that ABR selection aims to avoid rebuffering. Diagnose the installed version before changing tuning values.

Also consider the absence of samples. A large forward buffer can let playback continue without another fragment download. Until a new request occurs, the player may have no fresh transfer evidence. Forcing the highest rendition during this interval tests a manual override, not whether automatic recovery works.

Find manual locks and hard caps

Inspect the player UI, URL parameters, saved preferences, account entitlements, device rules, viewport logic, frame-drop protection, data-saver mode, and application state. Search code for every assignment that changes a level or maximum level, and log a reason with it.

Typical blockers include:

  • A viewer selected a fixed quality and the application persisted it.
  • “Auto” is displayed while a stale manual level remains active.
  • The player caps quality to its rendered dimensions or a configured device-pixel ratio.
  • An application sets a maximum for mobile devices, metered connections, battery state, or account tier.
  • Frame-drop handling lowers or caps the level after decoder stress.
  • A failed high rendition is temporarily avoided.
  • Server-side content steering or a pathway exposes a different level set.
  • A component recreated the player with old settings after network or page recovery.

Do not remove all caps at once. Change one controlled input, record the resulting maximum and next level, then restore it. A cap may protect decoding, data use, thermal behavior, or subscription rules even when network capacity is high.

Validate the multivariant playlist

The player can only choose from renditions it successfully parsed and can play. Save the multivariant playlist and list every EXT-X-STREAM-INF URI, BANDWIDTH, AVERAGE-BANDWIDTH, RESOLUTION, FRAME-RATE, CODECS, audio group, and video range.

RFC 8216 defines BANDWIDTH as peak segment bit rate and AVERAGE-BANDWIDTH as average segment bit rate. It warns that an inaccurate average value can cause stalls or prevent clients from playing the variant. Apple also publishes declared-versus-measured bandwidth constraints for its HLS authoring guidance. Do not relabel average encoder output as peak bandwidth without measuring the complete playable combination of video and associated renditions.

Check that:

  1. Renditions form a useful ladder rather than several nearly identical steps.
  2. Declared values rise consistently with actual delivery cost.
  3. CODECS includes every sample format used by the variant and its rendition groups.
  4. High-level media playlists and their first segments return successfully with the same authorization context.
  5. Variants are aligned closely enough for the supported player to switch cleanly.
  6. Resolution labels in the interface map to the intended rendition IDs.

Our synthetic four-second segment measured 4.8 Mbps even though its fictional level declared 3.0 Mbps. That controlled mismatch proves only the calculation bytes × 8 ÷ media duration; it does not prove a real playlist is misdeclared. Measure multiple authorized segments, include associated audio where required, and follow the applicable specification.

Separate time to first byte from transfer capacity

Calculate media transfer throughput from bytes and the relevant transfer interval, but retain connection and response phases separately. A small segment can spend most of its request time waiting for the first byte. A cached speed-test object cannot reveal an overloaded origin, slow authorization, a cold CDN path, or one failing rendition hostname.

For each fragment, record:

  • Level ID, declared bandwidth, resolution, and media duration.
  • Sanitized final host/path class and cache outcome.
  • Request start, first byte, completion, bytes transferred, retries, and status.
  • Whether the response was actually media rather than an HTML error body.
  • Buffer ahead when selection occurred and when download completed.
  • The estimate before and after the sample, if the player exposes both.

Compare low and high renditions from the same viewer session. If only high-level objects are slow or fail, changing the global estimator is unlikely to fix the delivery path.

Do not confuse rendition level with perceived sharpness

A player can report 1080p while the image still looks soft. The source may already be blurred, the encoder may allocate too few bits for the motion and detail, the player may be enlarged beyond the decoded dimensions, browser zoom may change the comparison, or the display may use a high device-pixel ratio. Conversely, a 720p rendition can look appropriate inside a small player.

Record videoWidth, videoHeight, the element's rendered rectangle, device pixel ratio, and the selected level's declared dimensions. Compare an approved frame from the rendition with the source through your encoding quality-control process. Do not infer a low level from eyesight alone, and do not claim an encoding defect without inspecting the actual media.

Recover safely instead of forcing the top level

The first recovery action should usually be to remove an unintended manual lock or incorrect cap, then allow automatic selection to observe new downloads. Forcing the highest level can create a stall and hide the original cause. hls.js documents nextAutoLevel as the next automatically selected level and notes that setting it can affect one fragment in relevant startup conditions; it should not be treated as proof that a durable automatic upgrade succeeded.

Define success as a sequence:

  1. Automatic selection is enabled and intended caps are known.
  2. New fragment samples arrive from the actual HLS delivery path.
  3. The bandwidth estimate changes in a plausible direction.
  4. A higher level is selected and its request succeeds.
  5. LEVEL_SWITCHED confirms the transition.
  6. Intrinsic video dimensions and progressing playback confirm the result on screen.
  7. The player remains stable long enough to exclude an immediate down-switch.

If returning to live position is also required, keep that decision separate. The live-latency and Go Live guide explains why quality selection and live position are different controls.

Run a controlled recovery matrix

Test the real supported player and an authorized ladder with repeatable network shaping. Record the shaping tool, target rate, latency, loss, duration, and whether the limitation applies to the browser, device, or a separate proxy.

DimensionMinimum cases
Recovery patternSudden return, gradual return, short spike, stable high capacity
Buffer stateNearly empty, normal, and large forward buffer
Selection stateAuto, each manual level, viewport cap, application cap
DeliveryWarm CDN hit, cold path, origin path, high-rendition failure fixture
ContentVOD and live, low/high motion, representative segment-size variation
Playback pathhls.js/MSE, native HLS where supported, official mobile or TV path
DisplaySmall player, fullscreen, different device-pixel ratios

A throttled desktop tab does not validate a phone radio transition, decoder limit, data-saver policy, or television. Label unavailable devices as untested. Never weaken authorization or publish signed media URLs to make a test reproducible.

Write the report around one selection opportunity

An actionable report might say: “Automatic selection was enabled. After the constrained period, level 0 was playing with 14 seconds buffered. The first new fragment completed at 7.6 Mbps, the estimate rose to 2.9 Mbps, and level 1 was requested. After additional samples, the estimate rose and level 2 switched successfully. Level 3 remained excluded because autoLevelCapping was 2, set by the viewport controller.” This identifies evidence and ownership without calling the algorithm stuck.

Include player and browser versions, level table, automatic/manual state, every cap with its setter, buffer ahead, fragment timings, estimates, level events, errors, intrinsic and display dimensions, and sanitized request IDs. State which platforms were physically tested and which conclusions came only from documentation or deterministic fixtures.

Primary references

Start with the level that is playing, the level selected next, and the rule that limits the maximum. Then follow the first real fragment samples after recovery. That evidence separates normal estimator memory from a manual lock, a hard cap, bad playlist declarations, a broken high rendition, or an image-quality problem that ABR cannot solve.