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.

What Muting Stops, and What Pausing StopsFour kinds of ongoing work for a playing video: audio decode, video decode, network transfer and layer compositing. Muting stops only the first. Pausing stops all four. A third column notes that scrolling the element off screen stops none of them by itself, which is why an explicit pause branch is required.Ongoing workmutedscrolled offpausedAudio decodestoppedcontinuesstoppedVideo decodecontinuescontinuesstoppedNetwork transfercontinuescontinuesstoppedLayer compositingcontinuesreducedstoppedScrolling an element out of view is not a pause. Only calling pause() is a pause.

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

TypeScript
// 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

TypeScript
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.

TypeScript
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:

TypeScript
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
});

Ten Minutes in a Background TabA comparison of network transfer for six background loops over ten minutes with the tab hidden. Without a visibility handler the transfer continues at a steady rate for the whole period. With one, transfer stops within a second of the tab being hidden and the line stays flat. The difference is the entire remainder of the session's video bandwidth.Cumulative transfer, six muted loops, tab hidden at t = 30stab hiddenno visibility handler — keeps streamingwith visibilitychange — flat from the moment it hides0 → 10 minutesThe gap is bytes the reader paid for and never saw a frame of.

Where the Savings Are, RankedThree levers ranked by how much ongoing work each removes for an off-screen video. Pausing stops decode, transfer and compositing together and is by far the largest. Releasing the source additionally frees the buffered data. Lowering the resolution helps least, because the number of simultaneously playing streams dominates the cost of any one of them.Ranked by ongoing work removed1. pause()Stops decode, transfer and compositing at once. Everything else is a refinement on top of it.2. release the source after a long absenceFrees the buffered data as well; costs a brief re-buffer if the reader returns.3. lower the resolutionLeast effective — the number of simultaneous streams dominates the cost of any single one.

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 = 0 on 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.


↑ Back to Media Playback Visibility Control