Most autoplay-on-scroll implementations get the play half right and treat the pause half as an afterthought. The pause half is where the savings are, and it needs one signal the observer cannot provide.
Problem / Scenario Context
A long-form article carries six muted background loops, one per section. Playback starts correctly as the reader scrolls. Nothing explicitly stops it, on the reasoning that a muted video the reader cannot see is harmless.
By the time the reader reaches the end of the article, six videos are decoding simultaneously. On a laptop the fans are audible; on a phone the device is warm and the battery indicator has moved. The Network panel shows all six still transferring. None of this is visible in a desktop development environment plugged into mains power, which is precisely why it ships.
Mechanics Explanation
A playing <video> does three kinds of work, and none of them stop because the element is off screen.
Decode. Each frame is decoded from the compressed stream, whether or not it will be presented. Hardware decode moves the work off the CPU; it does not remove it, and it is not free on battery.
Transfer. A streaming source keeps buffering ahead. A muted loop that nobody is watching continues to consume bandwidth, which on a metered connection is money.
Composite. The video's layer is updated every frame, and that repaint budget is shared with everything else on the page — including any scroll-driven effects that then have less headroom.
Muting removes only the audio path, which is the cheapest of the three. pause() stops all of them.
The observer covers one way a video stops being watched: the reader scrolled past it. It cannot see the other: the reader switched tabs or apps. Intersection is computed within the document, so a video scrolled into view keeps reporting a full ratio while the tab sits in the background.
Comparison Table: Pause Triggers
| Trigger | Signal | Covers |
|---|---|---|
| Scrolled out of view | IntersectionObserver |
the reader moved on within the page |
| Tab switched or minimised | visibilitychange |
the reader left the page entirely |
| Reduced motion enabled mid-session | matchMedia change |
the reader changed their preference |
| Data saver on | navigator.connection.saveData |
never start in the first place |
| Element removed from the DOM | component teardown | a route change tore the section down |
The second row is the one most implementations lack, and it is the one that matters most for battery: a reader who opens a background tab and leaves it there for an hour is the worst case, and the observer will report the video as fully visible the entire time.
Minimal Reproducible Example
// Covers scrolling, misses everything else
new IntersectionObserver(([e]) => {
const v = e.target as HTMLVideoElement;
if (e.isIntersecting) void v.play(); else v.pause();
}, { threshold: 0.5 }).observe(video);
// Switch tabs with the video in view: it keeps decoding and transferring, indefinitely.
Production-Safe Solution
const managed = new Set<HTMLVideoElement>();
const inView = new WeakSet<HTMLVideoElement>();
function reconcile(video: HTMLVideoElement): void {
const shouldPlay = inView.has(video) && document.visibilityState === 'visible';
if (shouldPlay === !video.paused) return;
if (shouldPlay) void video.play().catch(() => {});
else video.pause();
}
const observer = new IntersectionObserver((entries) => {
for (const e of entries) {
const v = e.target as HTMLVideoElement;
if (e.intersectionRatio >= 0.5) inView.add(v); else inView.delete(v);
reconcile(v);
}
}, { threshold: [0.35, 0.5] });
export function manage(video: HTMLVideoElement): () => void {
managed.add(video);
observer.observe(video);
return () => { observer.unobserve(video); managed.delete(video); inView.delete(video); video.pause(); };
}
// The signal the observer cannot see
document.addEventListener('visibilitychange', () => {
for (const v of managed) reconcile(v);
});
Two further refinements are worth the lines.
Do not start at all on a saving connection.
const conn = (navigator as any).connection;
const frugal = conn?.saveData === true || /^([23])g$/.test(conn?.effectiveType ?? '');
if (frugal) { video.controls = true; return; } // poster + controls, no autoplay
Release the buffer on long absences. Pausing stops the transfer but keeps what has been buffered. For a page likely to sit in a background tab for a long time, resetting the source frees it:
let idleTimer: number | undefined;
document.addEventListener('visibilitychange', () => {
clearTimeout(idleTimer);
if (document.visibilityState !== 'hidden') return;
idleTimer = window.setTimeout(() => {
for (const v of managed) { v.pause(); v.removeAttribute('src'); v.load(); }
}, 60_000); // an hour in a background tab should not hold six video buffers
});
Verification Steps
- Watch the Network panel while switching tabs: transfer should stop within a second, not taper.
- Watch the Performance panel's frame lane with videos off screen; there should be no video-decode work.
- Check on battery, unplugged, with a phone rather than a laptop — the difference is far more visible there.
- Confirm teardown pauses, not merely unobserves: a component that unmounts while its video is playing must stop it, or a detached element keeps decoding until collection.
Common Mistakes to Avoid
- Muting instead of pausing. It stops the cheapest of the three costs and leaves the other two running.
- Relying on the observer alone. It cannot see a tab switch, which is the longest-duration case.
- Setting
video.currentTime = 0on pause. It looks tidy and forces a re-buffer when playback resumes, spending more bandwidth than it saves. - Forgetting the removal path. An element removed from the DOM while playing continues until it is collected — see preventing memory leaks in long-running observers.
FAQ
Is a muted off-screen video really costing anything?
Yes. Muting stops audio decode only. Video frames are still decoded, the stream is still buffered over the network, and the compositor still updates the layer every frame. Those three are the expensive parts, and only pause stops them.
Why is visibilitychange needed if I already have an observer?
Because intersection is computed inside the document. Switching to another tab does not move the element, so a video scrolled into view keeps reporting full visibility while the tab is hidden — and keeps decoding. The two signals cover different ways of not being watched.
Should I clear the video source when the tab is hidden for a long time?
For pages likely to sit in a background tab, yes. Pausing stops new transfer but keeps the existing buffer in memory. Removing the source and calling load after a minute or so of being hidden releases it, at the cost of a brief re-buffer if the reader comes back.
Does setting currentTime to zero on pause help?
No, it hurts. Resetting the position discards the buffered data, so resuming has to fetch it again. Leave the position alone; a reader returning to a section generally expects it to continue rather than restart anyway.
Related
- Autoplaying Video Only When in Viewport — the play half of the same gate
- Lazy Loading YouTube iframes with IntersectionObserver — the same argument for embeds you cannot pause directly
- Media Playback Visibility Control — the four-input gate this page completes
↑ Back to Media Playback Visibility Control