IntersectionObserver

IntersectionObserver reports elements entering or leaving the viewport or a scrolling root, for lazy loading, infinite scroll and ad impressions. threshold sets the visible fractions that trigger callbacks; rootMargin grows the root box so work starts early.

Which rows of a scrolled list count as visible, with a 30px marginHTMLLive
<div id="feed" style="height: 100px; width: 300px; overflow-y: auto; border: 1px solid #999"></div>
<pre id="out" style="font: 14px/1.5 monospace; margin: 8px 0 0"></pre>
<script>
  const row = (_, i) => `<p style="height: 25px; margin: 0">${i + 1}</p>`;
  feed.innerHTML = Array.from({ length: 12 }, row).join('');
  feed.scrollTop = 110;  // rows 5-9 are (partly) visible
  const ids = (entries, inView) => entries.filter((e) => e.isIntersecting === inView)
    .map((e) => e.target.textContent).join(' ');
  const observer = new IntersectionObserver((entries) => {
    out.textContent += `in [${ids(entries, true)}]  out [${ids(entries, false)}]\n`;
  }, { root: feed, rootMargin: '0px 0px 30px 0px' });  // report rows 30px before they appear
  feed.querySelectorAll('p').forEach((p) => observer.observe(p));
</script>
Browser output of Listing 9.85
Browser output of 85

The first callback reports each target's initial state; row 10, still out of sight, is in because of rootMargin. Later callbacks list only rows whose state changed. Images and iframes need only loading="lazy".