IntersectionObserver replaces a scroll handler

Lazy-loading images with a scroll listener means running getBoundingClientRect for every candidate on every scroll event, which forces layout and is exactly the work that makes scrolling stutter.

const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;

    entry.target.src = entry.target.dataset.src;
    io.unobserve(entry.target);
  }
}, { rootMargin: '200px' });

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

The callback runs off the main thread’s critical path and the browser batches the intersection calculations, so the cost is roughly independent of how many elements are observed. rootMargin is what makes it feel instant rather than correct-but-late — starting the load two hundred pixels early means the image is usually there by the time it is visible. Unobserving after the first hit matters; without it the callback keeps firing for the rest of the session.