HLS Stops in the Background or After Screen Lock: A Recovery-First Diagnosis

Diagnose HLS playback that pauses, drifts, or fails after a tab switch, app switch, phone lock, page freeze, or browser discard.

An HLS player can work normally until the viewer changes tabs, switches apps, locks a phone, or leaves the device idle. On return, the picture may be paused, the audio may be missing, the player may be far behind live, or the next playlist request may fail. These symptoms look similar but can begin in different layers: application code, browser lifecycle management, operating-system media policy, an expired live window, authorization, or the media pipeline.

Do not promise that a web page will keep video running while hidden on every device. Treat continued background playback and reliable foreground recovery as separate product requirements. Test only streams you own or are authorized to inspect, and remove signed URLs, cookies, tokens, device identifiers, IP addresses, and private hostnames from shared logs.

Verification method — September 24, 2026: We ran a deterministic Node.js 24.12.0 recovery-policy fixture. Its synthetic live window advanced from media time 40–70 to 58–88. A saved position at 42 had expired, so the fixture selected a declared live-sync position at 76; a saved position at 62 remained seekable and was preserved. A VOD position at 120 inside a 0–300 range was preserved, an HTTP 403 selected reauthorization instead of repeated media retries, and wasDiscarded: true selected live reinitialization. This verifies only the fixture's range and decision arithmetic. It does not test a browser lifecycle transition, HLS player, media decoding, network, phone lock screen, operating-system background policy, or physical device. The fixture is stored at scripts/test-hls-background-recovery.cjs.

Name the transition you are testing

“It stops in the background” is not a reproducible report. Record the exact transition and the expected behavior before changing code.

TransitionWhat the page may observeWhat it does not prove
Another browser tab becomes activevisibilitychange and document.visibilityState === "hidden"That the page was frozen, discarded, or allowed to play audio
Browser or app goes to the backgroundA visibility change may be the last reliable eventThat a later unload-style event will run
Screen locksThe document can become hiddenThat every browser and OS keeps the same media or network policy
Page freezesFreezable tasks, including many timers and callbacks, stop runningThat the renderer was destroyed
Page is discardedThe page is removed to save resources and later reloadsThat JavaScript received a discard event at the time
Component unmounts or route changesYour application may destroy the player or clear the sourceThat the browser imposed a background policy

MDN documents visibilitychange for tab switches, window minimization, and mobile app switches. Chrome's Page Lifecycle guidance distinguishes hidden, frozen, terminated, and discarded states, and warns that the hidden transition is often the last reliably observable one on mobile. These are useful signals, not a cross-platform guarantee that media will continue.

Capture evidence before the page disappears

Add a short-lived diagnostic logger in a controlled build. Record monotonic time, wall-clock time, visibility, media state, current position, all seekable and buffered ranges, and the last player/network event. Do not log full signed URLs.

function ranges(value) {
  return Array.from(
    { length: value.length },
    (_, index) => ({ start: value.start(index), end: value.end(index) })
  );
}

function snapshot(video, event) {
  console.info('media-lifecycle', {
    event,
    monotonicMs: performance.now(),
    visibility: document.visibilityState,
    paused: video.paused,
    ended: video.ended,
    currentTime: video.currentTime,
    readyState: video.readyState,
    networkState: video.networkState,
    seekable: ranges(video.seekable),
    buffered: ranges(video.buffered),
  });
}

document.addEventListener('visibilitychange', () => snapshot(video, 'visibilitychange'));
for (const name of ['pause', 'play', 'playing', 'waiting', 'stalled', 'suspend', 'emptied', 'error']) {
  video.addEventListener(name, () => snapshot(video, name));
}

If your player exposes manifest, fragment, buffer, level, and fatal-error events, record those on the same monotonic timeline. Capture pagehide, pageshow, freeze, and resume where supported, but never depend on unload as the only place to save state or telemetry.

First exclude an application-created stop

Many background failures are self-inflicted. Search for every pause(), source removal, player destroy() or detachMedia(), route cleanup, state reset, and visibilitychange handler. Framework components may unmount when a view is hidden. An energy-saving handler may intentionally stop loading but lack a matching resume path. Two lifecycle listeners can also race: one restores playback while another reapplies an old paused state.

Add a reason to each intentional transition instead of logging only “paused.” A stack trace in a diagnostic build can identify the caller of a wrapper around pause() or teardown. Compare a minimal player page with the full application using the same authorized stream. If the minimal page recovers and the application does not, inspect application ownership before blaming HLS or the CDN.

First evidence after returnLikely boundaryNext controlled check
Player instance is missing or source is emptyComponent lifecycle or teardownTrace unmount, destroy, source assignment, and state restoration
Media is paused with no failed requestIntentional pause, platform policy, or lost user intentRecord the event sequence and test an explicit user resume action
play() rejectsBrowser playback policy or unavailable media stateLog the promise rejection name and message; do not swallow it
Playlist reload resumes but body is oldCDN cache or suspended reload loopCompare timestamps, media sequence, Age, and repeated bodies
First request returns 401/403Expired authorization or signed child URLRefresh authorization through the approved path
Old segment returns 404/410Saved live position left retentionCompare the new playlist window and seekable ranges
Bytes arrive but decode does not resumeMedia pipeline, timestamp, codec, or discontinuityPreserve player errors and inspect the first post-return media

Re-read the timeline after returning

Do not assume the position saved before backgrounding still exists. A sliding live playlist may advance while loading is throttled or stopped. Read the new media playlist, then capture video.seekable, the player live-sync position, selected rendition, discontinuities, and the saved currentTime.

For live playback, decide among three states:

  1. The saved position remains seekable: preserve it if the product supports deliberate DVR viewing.
  2. The saved position expired: join a validated live-sync target inside the current range.
  3. No reliable range exists yet: reload the timeline and wait; do not seek to an invented value.

For VOD, preserve the saved position only after it falls inside a current seekable range. For both live and VOD, clamp any target to a real range. MDN notes that live media can lose earlier content and that a requested currentTime may be adjusted to a supported position.

function contains(ranges, time) {
  return ranges.some(range => time >= range.start && time <= range.end);
}

function chooseLiveTarget({ savedTime, ranges, liveSyncPosition }) {
  if (!ranges.length) return null;
  if (contains(ranges, savedTime)) return savedTime;

  const latest = ranges.at(-1);
  return Math.min(latest.end, Math.max(latest.start, liveSyncPosition));
}

Use the player's documented live-sync position when available rather than seeking to the mathematical end. The live-latency and Go Live guide explains why a safe target normally remains behind the last advertised instant. If a seek snaps forward or backward, use the HLS seek and DVR diagnosis.

Treat playback intent as state, not a guess

Before the page becomes hidden, save whether playback was user-requested, the current position, content identity, stream type, selected tracks, volume, and a sanitized authorization generation. Do not equate paused === false with permission to restart forever: the user may pause while the page is hidden through system media controls.

On return:

  1. Recreate the player only if it was destroyed or the page was discarded.
  2. Refresh authorization when the evidence shows expiry; do not loop on 401/403.
  3. Load the current playlist and wait for a real seekable range.
  4. Choose a VOD, DVR, or live-sync target according to the product promise.
  5. Call play() only when saved user intent still requests playback.
  6. Handle the returned promise and expose a clear tap-to-resume control if it rejects.
  7. Mark recovery complete only after playing and progressing currentTime, not after the method call.

This keeps recovery idempotent. Repeated visibilitychange or framework renders should not create multiple HLS instances, duplicate listeners, competing playlist reloads, or a loop of play/pause calls.

Separate authorization failure from media failure

Short-lived signatures frequently expire while a device is idle. The master playlist may still be cached while the next media playlist, key, initialization section, or segment uses an expired child URL. Record the resource class and sanitized status of the first post-return request.

A 401 or 403 is not fixed by seeking or clearing the media buffer. Refresh the session or obtain a newly authorized playback URL through the service's supported flow. Never append credentials from one origin to another, publish signed URLs, or weaken access control to make background recovery appear successful.

If the playlist is fresh but an advertised object returns 404, follow the stale playlist versus missing segment decision path. If requests are simply too slow after the return, the HLS buffering diagnosis separates throughput, segment availability, decode, and player state.

Design the interface for honest recovery

The interface should distinguish Paused, Reconnecting, Returning to live, Behind live, and Tap to resume. Do not display “Live” while the page is still using an old playlist or before the playhead progresses. Preserve a viewer's intentional DVR position when it remains supported; offer a separate Go Live action instead of silently overriding it.

If background audio is a product requirement, document the supported browser, installation mode, operating system, content type, user-gesture requirement, lock-screen controls, and interruption behavior. A successful test in one desktop tab is not evidence for iOS Safari, an installed PWA, Android Chrome, a WebView, or a native application.

Run a physical-device transition matrix

Automation can verify decision code and rendered pages, but platform policy needs real devices. Use the same authorized stream and repeat each transition long enough for a live window or signature to advance.

Test dimensionMinimum cases to record
Device pathSupported phone, tablet, desktop, installed PWA, WebView, or native wrapper as applicable
TransitionTab switch, app switch, screen lock, unlock, incoming interruption, browser process reclaim
DurationShort return, beyond one target-duration cycle, beyond the DVR window, beyond URL expiry
MediaVOD, sliding live, event/DVR, video with audio, audio-only if supported
NetworkUnchanged network, offline while hidden, Wi-Fi-to-cellular change, captive reauthentication
Expected resultContinue, pause safely, restore the saved point, join live sync, or require user action

For every case, record device and OS version, browser or WebView version, player version, transition times, visibility/lifecycle events received, saved intent, playlist sequence before and after, seekable ranges, first post-return request, play-promise result, and time until confirmed playing. Label any platform not physically tested as documentation-reviewed or untested.

Write the bug report around one return

An actionable report might say: “At media time 42, the viewer locked the test phone while the live range was 40–70. On return, the current range was 58–88, so 42 had expired. The refreshed playlist succeeded, the player exposed a live-sync position of 76, and recovery reached playing at 76 after one user-authorized play request.” That identifies timeline expiry instead of merely saying “mobile playback stopped.”

Include the expected background behavior, saved playback intent, exact transition, lifecycle events that actually fired, before/after timeline, sanitized network evidence, player ownership changes, promise rejection details, and physical-device identity. Do not claim that the OS killed, froze, or discarded the page without corresponding evidence.

Primary references

Start with the transition the user actually performed, then follow the first state or request that changed. Save playback intent before the page disappears, rebuild the current timeline after return, and recover to a supported position instead of replaying stale state. That makes foreground recovery testable even when continued background playback is not a platform guarantee.