Intersection observer is in every current browser

Last year it was Chrome-only and needed a polyfill. This year Firefox and Edge have shipped it, which moves it from an experiment to the default answer for anything that depends on an element being visible.

const io = new IntersectionObserver(entries => {
  entries.forEach(e => {
    if (e.isIntersecting) { load(e.target); io.unobserve(e.target); }
  });
}, { rootMargin: '200px 0px', threshold: 0 });

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

Safari is the remaining gap and the polyfill is small, so the pattern is safe to adopt. rootMargin is what makes lazy loading feel instant rather than late — starting the fetch 200 pixels early means the image has usually arrived by the time it is on screen. unobserve matters on a long list: an observer watching two thousand elements it has already handled is work nobody needs.