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
- The hero photo above the fold: no observed lazy loading, no loading=lazy; fetchpriority=high wins there.
- Everything measured below the fold: native loading=lazy first; almost no project needs more.
- 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.
