Scroll-event code fights for main-thread time: each event schedules layout reads, and a hundred listeners make one scroll jank. IntersectionObserver replaces the whole genre: the browser tells you, asynchronously, when elements enter the viewport.

const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    if (!e.isIntersecting) continue;
    const img = e.target;
    img.src = img.dataset.src;
    io.unobserve(img); // one-shot work: stop watching after first hit
  }
}, { rootMargin: "200px" });

document.querySelectorAll("img[data-src]")
  .forEach((img) => io.observe(img));

The rules that keep it sane

  • One observer per use, not per element: unobserve after a one-shot job like lazy loading; long-lived observers stay for things like sticky-tracking widgets.
  • rootMargin pre-loads before arrival: 200px ahead for images, more for a video embed.
  • Never parse layout in the callback style: the callback fires off the main loop, so keep its work small and non-blocking.
  • Design for the observer never firing: without a script, plain loading=lazy on images, and no hidden content your page depends on.

The lazy-image decision table

  1. The hero photo above the fold: no observed lazy loading, no loading=lazy; fetchpriority=high wins there.
  2. Everything measured below the fold: native loading=lazy first; almost no project needs more.
  3. Out-of-DOM widgets (tweet embeds, maps): the observer pattern above, so the iframe loads only when approached.

This site invented nothing: heroes earn their fetchpriority, body images carry loading=lazy, and scripts defer to after paint. The observer completes that story exactly when plain attributes stop being enough, gacor in scope.