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.
| Transition | What the page may observe | What it does not prove |
|---|---|---|
| Another browser tab becomes active | visibilitychange and document.visibilityState === "hidden" | That the page was frozen, discarded, or allowed to play audio |
| Browser or app goes to the background | A visibility change may be the last reliable event | That a later unload-style event will run |
| Screen locks | The document can become hidden | That every browser and OS keeps the same media or network policy |
| Page freezes | Freezable tasks, including many timers and callbacks, stop running | That the renderer was destroyed |
| Page is discarded | The page is removed to save resources and later reloads | That JavaScript received a discard event at the time |
| Component unmounts or route changes | Your application may destroy the player or clear the source | That 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 return | Likely boundary | Next controlled check |
|---|---|---|
| Player instance is missing or source is empty | Component lifecycle or teardown | Trace unmount, destroy, source assignment, and state restoration |
| Media is paused with no failed request | Intentional pause, platform policy, or lost user intent | Record the event sequence and test an explicit user resume action |
play() rejects | Browser playback policy or unavailable media state | Log the promise rejection name and message; do not swallow it |
| Playlist reload resumes but body is old | CDN cache or suspended reload loop | Compare timestamps, media sequence, Age, and repeated bodies |
| First request returns 401/403 | Expired authorization or signed child URL | Refresh authorization through the approved path |
| Old segment returns 404/410 | Saved live position left retention | Compare the new playlist window and seekable ranges |
| Bytes arrive but decode does not resume | Media pipeline, timestamp, codec, or discontinuity | Preserve 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:
- The saved position remains seekable: preserve it if the product supports deliberate DVR viewing.
- The saved position expired: join a validated live-sync target inside the current range.
- 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:
- Recreate the player only if it was destroyed or the page was discarded.
- Refresh authorization when the evidence shows expiry; do not loop on 401/403.
- Load the current playlist and wait for a real seekable range.
- Choose a VOD, DVR, or live-sync target according to the product promise.
- Call
play()only when saved user intent still requests playback. - Handle the returned promise and expose a clear tap-to-resume control if it rejects.
- Mark recovery complete only after
playingand progressingcurrentTime, 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 dimension | Minimum cases to record |
|---|---|
| Device path | Supported phone, tablet, desktop, installed PWA, WebView, or native wrapper as applicable |
| Transition | Tab switch, app switch, screen lock, unlock, incoming interruption, browser process reclaim |
| Duration | Short return, beyond one target-duration cycle, beyond the DVR window, beyond URL expiry |
| Media | VOD, sliding live, event/DVR, video with audio, audio-only if supported |
| Network | Unchanged network, offline while hidden, Wi-Fi-to-cellular change, captive reauthentication |
| Expected result | Continue, 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
- MDN: Page Visibility API
- MDN:
visibilitychangeevent - Chrome for Developers: Page Lifecycle API
- MDN: HTMLMediaElement
play() - MDN: HTMLMediaElement
currentTime - RFC 8216: HTTP Live Streaming
- hls.js API: liveSyncPosition, latency, and lifecycle methods
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.